Image uploads (#576)
* Add icon to add images
* Open file explorer to select image
* Upload images to S3 on selection
* Show uploaded images below text input
* Link and remove image
* Fetch unsubmitted images from database
* Mark S3 images as submitted in imgproxy job
* Add margin-top
* Mark images as submitted on client after successful mutation
* Also delete objects in S3
* Allow items to have multiple uploads linked
* Overwrite old avatar
* Add fees for presigned URLs
* Use Github style upload
* removed upfront fees
* removed images provider since we no longer need to keep track of unsubmitted images on the client
* removed User.images resolver
* removed deleteImage mutation
* use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload
* Add Upload.paid boolean column
One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation).
Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for.
This makes fee calculation easier since no JOINs are required.
* Add image fees during item creation/update
* we calculate image fees during item creation and update now
* function imageFees returns queries which deduct fees from user and mark images as paid + fees
* queries need to be run inside same transaction as item creation/update
* Allow anons to get presigned URLs
* Add comments regarding avatar upload
* Use megabytes in error message
* Remove unnecessary avatar check during image fees calculation
* Show image fees in frontend
* Also update image fees on blur
This makes sure that the images fees reflect the current state. For example, if an image was removed.
We could also add debounced requests.
* Show amount of unpaid images in receipt
* Fix fees in sats deducted from msats
* Fix algebraic order of fees
Spam fees must come immediately after the base fee since it multiplies the base fee.
* Fix image fees in edit receipt
* Fix stale fees shown
If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet.
* Add 0 base fee in edit receipt
* Remove 's' from 'image fees' in receipts
* Remove unnecessary async
* Remove 'Uploading <name>...' from text input on error
* Support upload of multiple files at once
* Add schedule to delete unused images
* Fix image fee display in receipts
* Use Drag and Drop API for image upload
* Remove dragOver style on drop
* Increase max upload size to 10MB to allow HQ camera pictures
* Fix free upload quota
* Fix stale image fees served
* Fix bad image fee return statements
* Fix multiplication with feesPerImage
* Fix NULL returned for size24h, sizeNow
* Remove unnecessary text field in query
* refactor: Unify <ImageUpload> and <Upload> component
* Add avatar cache busting using random query param
* Calculate image fee info in postgres function
* we now calculate image fee info in a postgres function which is much cleaner
* we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item!
* reversed changes in `serializeInvoiceable`
* Fix line break in receipt
* Update upload limits
* Add comment about `e.target.value = null`
* Use debounce instead of onBlur to update image fees info
* Fix invoice amount
* Refactor avatar upload control flow
* Update image fees in onChange
* Fix rescheduling of other jobs
* also update schedule from every minute to every hour
* Add image fees in calling context
* keep item ids on uploads
* Fix incompatible onSubmit signature
* Revert "keep item ids on uploads"
This reverts commit 4688962abc
.
* many2many item uploads
* pretty subdomain for images
* handle upload conditions for profile images and job logos
---------
Co-authored-by: ekzyis <ek@ekzyis.com>
Co-authored-by: ekzyis <ek@stacker.news>
This commit is contained in:
parent
5fd74e9329
commit
8f590425dc
|
@ -0,0 +1,23 @@
|
||||||
|
import { ANON_USER_ID, AWS_S3_URL_REGEXP } from '../../lib/constants'
|
||||||
|
import { msatsToSats } from '../../lib/format'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
Query: {
|
||||||
|
imageFeesInfo: async (parent, { s3Keys }, { models, me }) => {
|
||||||
|
return imageFeesInfo(s3Keys, { models, me })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadIdsFromText (text, { models }) {
|
||||||
|
if (!text) return null
|
||||||
|
return [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function imageFeesInfo (s3Keys, { models, me }) {
|
||||||
|
const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : ANON_USER_ID, s3Keys)
|
||||||
|
const imageFee = msatsToSats(info.imageFeeMsats)
|
||||||
|
const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
|
||||||
|
const totalFees = msatsToSats(totalFeesMsats)
|
||||||
|
return { ...info, imageFee, totalFees, totalFeesMsats }
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ import price from './price'
|
||||||
import { GraphQLJSONObject as JSONObject } from 'graphql-type-json'
|
import { GraphQLJSONObject as JSONObject } from 'graphql-type-json'
|
||||||
import admin from './admin'
|
import admin from './admin'
|
||||||
import blockHeight from './blockHeight'
|
import blockHeight from './blockHeight'
|
||||||
|
import image from './image'
|
||||||
import { GraphQLScalarType, Kind } from 'graphql'
|
import { GraphQLScalarType, Kind } from 'graphql'
|
||||||
|
|
||||||
const date = new GraphQLScalarType({
|
const date = new GraphQLScalarType({
|
||||||
|
@ -45,4 +46,4 @@ const date = new GraphQLScalarType({
|
||||||
})
|
})
|
||||||
|
|
||||||
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
||||||
upload, search, growth, rewards, referrals, price, admin, blockHeight, { JSONObject }, { Date: date }]
|
upload, search, growth, rewards, referrals, price, admin, blockHeight, image, { JSONObject }, { Date: date }]
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { sendUserNotification } from '../webPush'
|
||||||
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item'
|
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item'
|
||||||
import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications'
|
import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications'
|
||||||
import { datePivot } from '../../lib/time'
|
import { datePivot } from '../../lib/time'
|
||||||
|
import { imageFeesInfo, uploadIdsFromText } from './image'
|
||||||
|
|
||||||
export async function commentFilterClause (me, models) {
|
export async function commentFilterClause (me, models) {
|
||||||
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
|
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
|
||||||
|
@ -1090,10 +1091,13 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
|
||||||
item = { subName, userId: me.id, ...item }
|
item = { subName, userId: me.id, ...item }
|
||||||
const fwdUsers = await getForwardUsers(models, forward)
|
const fwdUsers = await getForwardUsers(models, forward)
|
||||||
|
|
||||||
|
const uploadIds = uploadIdsFromText(item.text, { models })
|
||||||
|
const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me })
|
||||||
|
|
||||||
item = await serializeInvoicable(
|
item = await serializeInvoicable(
|
||||||
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB) AS "Item"`,
|
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[]) AS "Item"`,
|
||||||
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)),
|
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
|
||||||
{ models, lnd, hash, hmac, me }
|
{ models, lnd, hash, hmac, me, enforceFee: imgFees }
|
||||||
)
|
)
|
||||||
|
|
||||||
await createMentions(item, models)
|
await createMentions(item, models)
|
||||||
|
@ -1123,11 +1127,14 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
|
||||||
item.url = removeTracking(item.url)
|
item.url = removeTracking(item.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const enforceFee = me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0)))
|
const uploadIds = uploadIdsFromText(item.text, { models })
|
||||||
|
const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me })
|
||||||
|
|
||||||
|
const enforceFee = (me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0)))) + imgFees
|
||||||
item = await serializeInvoicable(
|
item = await serializeInvoicable(
|
||||||
models.$queryRawUnsafe(
|
models.$queryRawUnsafe(
|
||||||
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`,
|
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[]) AS "Item"`,
|
||||||
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)),
|
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
|
||||||
{ models, lnd, hash, hmac, me, enforceFee }
|
{ models, lnd, hash, hmac, me, enforceFee }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,62 +1,42 @@
|
||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
import AWS from 'aws-sdk'
|
import { ANON_USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '../../lib/constants'
|
||||||
import { IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_TYPES_ALLOW } from '../../lib/constants'
|
import { createPresignedPost } from '../s3'
|
||||||
|
|
||||||
const bucketRegion = 'us-east-1'
|
|
||||||
|
|
||||||
AWS.config.update({
|
|
||||||
region: bucketRegion
|
|
||||||
})
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Mutation: {
|
Mutation: {
|
||||||
getSignedPOST: async (parent, { type, size, width, height }, { models, me }) => {
|
getSignedPOST: async (parent, { type, size, width, height, avatar }, { models, me }) => {
|
||||||
if (!me) {
|
|
||||||
throw new GraphQLError('you must be logged in to get a signed url', { extensions: { code: 'FORBIDDEN' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) {
|
if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) {
|
||||||
throw new GraphQLError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`, { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`, { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (size > UPLOAD_SIZE_MAX) {
|
if (size > UPLOAD_SIZE_MAX) {
|
||||||
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX} bytes`, { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX / (1024 ** 2)} megabytes`, { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avatar && size > UPLOAD_SIZE_MAX_AVATAR) {
|
||||||
|
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX_AVATAR / (1024 ** 2)} megabytes`, { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (width * height > IMAGE_PIXELS_MAX) {
|
if (width * height > IMAGE_PIXELS_MAX) {
|
||||||
throw new GraphQLError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`, { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`, { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// create upload record
|
const imgParams = {
|
||||||
const upload = await models.upload.create({
|
type,
|
||||||
data: {
|
size,
|
||||||
type,
|
width,
|
||||||
size,
|
height,
|
||||||
width,
|
userId: me?.id || ANON_USER_ID,
|
||||||
height,
|
paid: false
|
||||||
userId: me.id
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// get presigned POST ur
|
if (avatar) {
|
||||||
const s3 = new AWS.S3({ apiVersion: '2006-03-01' })
|
if (!me) throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||||
const res = await new Promise((resolve, reject) => {
|
imgParams.paid = undefined
|
||||||
s3.createPresignedPost({
|
}
|
||||||
Bucket: process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET,
|
|
||||||
Fields: {
|
|
||||||
key: String(upload.id)
|
|
||||||
},
|
|
||||||
Expires: 300,
|
|
||||||
Conditions: [
|
|
||||||
{ 'Content-Type': type },
|
|
||||||
{ 'Cache-Control': 'max-age=31536000' },
|
|
||||||
{ acl: 'public-read' },
|
|
||||||
['content-length-range', size, size]
|
|
||||||
]
|
|
||||||
}, (err, preSigned) => { if (err) { reject(err) } else { resolve(preSigned) } })
|
|
||||||
})
|
|
||||||
|
|
||||||
return res
|
const upload = await models.upload.create({ data: { ...imgParams } })
|
||||||
|
return createPresignedPost({ key: String(upload.id), type, size })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
import AWS from 'aws-sdk'
|
||||||
|
|
||||||
|
const bucketRegion = 'us-east-1'
|
||||||
|
const Bucket = process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET
|
||||||
|
|
||||||
|
AWS.config.update({
|
||||||
|
region: bucketRegion
|
||||||
|
})
|
||||||
|
|
||||||
|
export function createPresignedPost ({ key, type, size }) {
|
||||||
|
const s3 = new AWS.S3({ apiVersion: '2006-03-01' })
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
s3.createPresignedPost({
|
||||||
|
Bucket,
|
||||||
|
Fields: { key },
|
||||||
|
Expires: 300,
|
||||||
|
Conditions: [
|
||||||
|
{ 'Content-Type': type },
|
||||||
|
{ 'Cache-Control': 'max-age=31536000' },
|
||||||
|
{ acl: 'public-read' },
|
||||||
|
['content-length-range', size, size]
|
||||||
|
]
|
||||||
|
}, (err, preSigned) => { err ? reject(err) : resolve(preSigned) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteObjects (keys) {
|
||||||
|
const s3 = new AWS.S3({ apiVersion: '2006-03-01' })
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
s3.deleteObjects({
|
||||||
|
Bucket,
|
||||||
|
Delete: {
|
||||||
|
Objects: keys.map(key => ({ Key: String(key) }))
|
||||||
|
}
|
||||||
|
}, (err, data) => { err ? reject(err) : resolve(keys) })
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
|
export default gql`
|
||||||
|
type ImageFeesInfo {
|
||||||
|
totalFees: Int!
|
||||||
|
totalFeesMsats: Int!
|
||||||
|
imageFee: Int!
|
||||||
|
imageFeeMsats: Int!
|
||||||
|
nUnpaid: Int!
|
||||||
|
bytesUnpaid: Int!
|
||||||
|
bytes24h: Int!
|
||||||
|
}
|
||||||
|
extend type Query {
|
||||||
|
imageFeesInfo(s3Keys: [Int]!): ImageFeesInfo!
|
||||||
|
}
|
||||||
|
`
|
|
@ -16,6 +16,7 @@ import referrals from './referrals'
|
||||||
import price from './price'
|
import price from './price'
|
||||||
import admin from './admin'
|
import admin from './admin'
|
||||||
import blockHeight from './blockHeight'
|
import blockHeight from './blockHeight'
|
||||||
|
import image from './image'
|
||||||
|
|
||||||
const common = gql`
|
const common = gql`
|
||||||
type Query {
|
type Query {
|
||||||
|
@ -35,4 +36,4 @@ const common = gql`
|
||||||
`
|
`
|
||||||
|
|
||||||
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
|
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
|
||||||
sub, upload, growth, rewards, referrals, price, admin, blockHeight]
|
sub, upload, growth, rewards, referrals, price, admin, blockHeight, image]
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!): SignedPost!
|
getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!, avatar: Boolean): SignedPost!
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignedPost {
|
type SignedPost {
|
||||||
|
|
|
@ -45,6 +45,18 @@ export default gql`
|
||||||
email: String
|
email: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Image {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Date!
|
||||||
|
updatedAt: Date!
|
||||||
|
type: String!
|
||||||
|
size: Int!
|
||||||
|
width: Int
|
||||||
|
height: Int
|
||||||
|
itemId: Int
|
||||||
|
userId: Int!
|
||||||
|
}
|
||||||
|
|
||||||
type User {
|
type User {
|
||||||
id: ID!
|
id: ID!
|
||||||
createdAt: Date!
|
createdAt: Date!
|
||||||
|
|
|
@ -2,10 +2,10 @@ import { useRef, useState } from 'react'
|
||||||
import AvatarEditor from 'react-avatar-editor'
|
import AvatarEditor from 'react-avatar-editor'
|
||||||
import Button from 'react-bootstrap/Button'
|
import Button from 'react-bootstrap/Button'
|
||||||
import BootstrapForm from 'react-bootstrap/Form'
|
import BootstrapForm from 'react-bootstrap/Form'
|
||||||
import Upload from './upload'
|
|
||||||
import EditImage from '../svgs/image-edit-fill.svg'
|
import EditImage from '../svgs/image-edit-fill.svg'
|
||||||
import Moon from '../svgs/moon-fill.svg'
|
import Moon from '../svgs/moon-fill.svg'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
|
import { ImageUpload } from './image'
|
||||||
|
|
||||||
export default function Avatar ({ onSuccess }) {
|
export default function Avatar ({ onSuccess }) {
|
||||||
const [uploading, setUploading] = useState()
|
const [uploading, setUploading] = useState()
|
||||||
|
@ -49,27 +49,41 @@ export default function Avatar ({ onSuccess }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Upload
|
<ImageUpload
|
||||||
as={({ onClick }) =>
|
avatar
|
||||||
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
|
|
||||||
{uploading
|
|
||||||
? <Moon className='fill-white spin' />
|
|
||||||
: <EditImage className='fill-white' />}
|
|
||||||
</div>}
|
|
||||||
onError={e => {
|
onError={e => {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}}
|
}}
|
||||||
onSelect={(file, upload) => {
|
onSelect={(file, upload) => {
|
||||||
showModal(onClose => <Body onClose={onClose} file={file} upload={upload} />)
|
return new Promise((resolve, reject) =>
|
||||||
|
showModal(onClose => (
|
||||||
|
<Body
|
||||||
|
onClose={() => {
|
||||||
|
onClose()
|
||||||
|
resolve()
|
||||||
|
}}
|
||||||
|
file={file}
|
||||||
|
upload={async (blob) => {
|
||||||
|
await upload(blob)
|
||||||
|
resolve(blob)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)))
|
||||||
}}
|
}}
|
||||||
onSuccess={async key => {
|
onSuccess={({ id }) => {
|
||||||
onSuccess && onSuccess(key)
|
onSuccess?.(id)
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}}
|
}}
|
||||||
onStarted={() => {
|
onUpload={() => {
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<div className='position-absolute p-1 bg-dark pointer' style={{ bottom: '0', right: '0' }}>
|
||||||
|
{uploading
|
||||||
|
? <Moon className='fill-white spin' />
|
||||||
|
: <EditImage className='fill-white' />}
|
||||||
|
</div>
|
||||||
|
</ImageUpload>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ import AnonIcon from '../svgs/spy-fill.svg'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) {
|
function Receipt ({ cost, repetition, imageFeesInfo, baseFee, parentId, boost }) {
|
||||||
return (
|
return (
|
||||||
<Table className={styles.receipt} borderless size='sm'>
|
<Table className={styles.receipt} borderless size='sm'>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -20,16 +20,16 @@ function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) {
|
||||||
<td>{numWithUnits(baseFee, { abbreviate: false })}</td>
|
<td>{numWithUnits(baseFee, { abbreviate: false })}</td>
|
||||||
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
|
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
|
||||||
</tr>
|
</tr>
|
||||||
{hasImgLink &&
|
|
||||||
<tr>
|
|
||||||
<td>x 10</td>
|
|
||||||
<td align='right' className='font-weight-light'>image/link fee</td>
|
|
||||||
</tr>}
|
|
||||||
{repetition > 0 &&
|
{repetition > 0 &&
|
||||||
<tr>
|
<tr>
|
||||||
<td>x 10<sup>{repetition}</sup></td>
|
<td>x 10<sup>{repetition}</sup></td>
|
||||||
<td className='font-weight-light' align='right'>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</td>
|
<td className='font-weight-light' align='right'>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</td>
|
||||||
</tr>}
|
</tr>}
|
||||||
|
{imageFeesInfo.totalFees > 0 &&
|
||||||
|
<tr>
|
||||||
|
<td>+ {imageFeesInfo.nUnpaid} x {numWithUnits(imageFeesInfo.imageFee, { abbreviate: false })}</td>
|
||||||
|
<td align='right' className='font-weight-light'>image fee</td>
|
||||||
|
</tr>}
|
||||||
{boost > 0 &&
|
{boost > 0 &&
|
||||||
<tr>
|
<tr>
|
||||||
<td>+ {numWithUnits(boost, { abbreviate: false })}</td>
|
<td>+ {numWithUnits(boost, { abbreviate: false })}</td>
|
||||||
|
@ -69,7 +69,7 @@ function AnonInfo () {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow, disabled }) {
|
export default function FeeButton ({ parentId, baseFee, ChildButton, variant, text, alwaysShow, disabled }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
baseFee = me ? baseFee : (parentId ? ANON_COMMENT_FEE : ANON_POST_FEE)
|
baseFee = me ? baseFee : (parentId ? ANON_COMMENT_FEE : ANON_POST_FEE)
|
||||||
const query = parentId
|
const query = parentId
|
||||||
|
@ -79,46 +79,43 @@ export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton,
|
||||||
const repetition = me ? data?.itemRepetition || 0 : 0
|
const repetition = me ? data?.itemRepetition || 0 : 0
|
||||||
const formik = useFormikContext()
|
const formik = useFormikContext()
|
||||||
const boost = Number(formik?.values?.boost) || 0
|
const boost = Number(formik?.values?.boost) || 0
|
||||||
const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost)
|
const cost = baseFee * Math.pow(10, repetition) + Number(boost)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
formik?.setFieldValue('cost', cost)
|
formik?.setFieldValue('cost', cost)
|
||||||
}, [formik?.getFieldProps('cost').value, cost])
|
}, [formik?.getFieldProps('cost').value, cost])
|
||||||
|
|
||||||
|
const imageFeesInfo = formik?.getFieldProps('imageFeesInfo').value || { totalFees: 0 }
|
||||||
|
const totalCost = cost + imageFeesInfo.totalFees
|
||||||
|
|
||||||
const show = alwaysShow || !formik?.isSubmitting
|
const show = alwaysShow || !formik?.isSubmitting
|
||||||
return (
|
return (
|
||||||
<div className={styles.feeButton}>
|
<div className={styles.feeButton}>
|
||||||
<ActionTooltip overlayText={numWithUnits(cost, { abbreviate: false })}>
|
<ActionTooltip overlayText={numWithUnits(totalCost, { abbreviate: false })}>
|
||||||
<ChildButton variant={variant} disabled={disabled}>{text}{cost > 1 && show && <small> {numWithUnits(cost, { abbreviate: false })}</small>}</ChildButton>
|
<ChildButton variant={variant} disabled={disabled}>{text}{totalCost > 1 && show && <small> {numWithUnits(totalCost, { abbreviate: false })}</small>}</ChildButton>
|
||||||
</ActionTooltip>
|
</ActionTooltip>
|
||||||
{!me && <AnonInfo />}
|
{!me && <AnonInfo />}
|
||||||
{cost > baseFee && show &&
|
{totalCost > baseFee && show &&
|
||||||
<Info>
|
<Info>
|
||||||
<Receipt baseFee={baseFee} hasImgLink={hasImgLink} repetition={repetition} cost={cost} parentId={parentId} boost={boost} />
|
<Receipt baseFee={baseFee} imageFeesInfo={imageFeesInfo} repetition={repetition} cost={totalCost} parentId={parentId} boost={boost} />
|
||||||
</Info>}
|
</Info>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) {
|
function EditReceipt ({ cost, paidSats, imageFeesInfo, boost, parentId }) {
|
||||||
return (
|
return (
|
||||||
<Table className={styles.receipt} borderless size='sm'>
|
<Table className={styles.receipt} borderless size='sm'>
|
||||||
<tbody>
|
<tbody>
|
||||||
{addImgLink &&
|
<tr>
|
||||||
<>
|
<td>{numWithUnits(0, { abbreviate: false })}</td>
|
||||||
<tr>
|
<td align='right' className='font-weight-light'>edit fee</td>
|
||||||
<td>{numWithUnits(paidSats, { abbreviate: false })}</td>
|
</tr>
|
||||||
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
|
{imageFeesInfo.totalFees > 0 &&
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<td>+ {imageFeesInfo.nUnpaid} x {numWithUnits(imageFeesInfo.imageFee, { abbreviate: false })}</td>
|
||||||
<td>x 10</td>
|
<td align='right' className='font-weight-light'>image fee</td>
|
||||||
<td align='right' className='font-weight-light'>image/link fee</td>
|
</tr>}
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>- {numWithUnits(paidSats, { abbreviate: false })}</td>
|
|
||||||
<td align='right' className='font-weight-light'>already paid</td>
|
|
||||||
</tr>
|
|
||||||
</>}
|
|
||||||
{boost > 0 &&
|
{boost > 0 &&
|
||||||
<tr>
|
<tr>
|
||||||
<td>+ {numWithUnits(boost, { abbreviate: false })}</td>
|
<td>+ {numWithUnits(boost, { abbreviate: false })}</td>
|
||||||
|
@ -135,25 +132,27 @@ function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton, variant, text, alwaysShow, parentId }) {
|
export function EditFeeButton ({ paidSats, ChildButton, variant, text, alwaysShow, parentId }) {
|
||||||
const formik = useFormikContext()
|
const formik = useFormikContext()
|
||||||
const boost = (formik?.values?.boost || 0) - (formik?.initialValues?.boost || 0)
|
const boost = (formik?.values?.boost || 0) - (formik?.initialValues?.boost || 0)
|
||||||
const addImgLink = hasImgLink && !hadImgLink
|
const cost = Number(boost)
|
||||||
const cost = (addImgLink ? paidSats * 9 : 0) + Number(boost)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
formik?.setFieldValue('cost', cost)
|
formik?.setFieldValue('cost', cost)
|
||||||
}, [formik?.getFieldProps('cost').value, cost])
|
}, [formik?.getFieldProps('cost').value, cost])
|
||||||
|
|
||||||
|
const imageFeesInfo = formik?.getFieldProps('imageFeesInfo').value || { totalFees: 0 }
|
||||||
|
const totalCost = cost + imageFeesInfo.totalFees
|
||||||
|
|
||||||
const show = alwaysShow || !formik?.isSubmitting
|
const show = alwaysShow || !formik?.isSubmitting
|
||||||
return (
|
return (
|
||||||
<div className='d-flex align-items-center'>
|
<div className='d-flex align-items-center'>
|
||||||
<ActionTooltip overlayText={numWithUnits(cost >= 0 ? cost : 0, { abbreviate: false })}>
|
<ActionTooltip overlayText={numWithUnits(totalCost >= 0 ? totalCost : 0, { abbreviate: false })}>
|
||||||
<ChildButton variant={variant}>{text}{cost > 0 && show && <small> {numWithUnits(cost, { abbreviate: false })}</small>}</ChildButton>
|
<ChildButton variant={variant}>{text}{totalCost > 0 && show && <small> {numWithUnits(totalCost, { abbreviate: false })}</small>}</ChildButton>
|
||||||
</ActionTooltip>
|
</ActionTooltip>
|
||||||
{cost > 0 && show &&
|
{totalCost > 0 && show &&
|
||||||
<Info>
|
<Info>
|
||||||
<EditReceipt paidSats={paidSats} addImgLink={addImgLink} cost={cost} parentId={parentId} boost={boost} />
|
<EditReceipt paidSats={paidSats} imageFeesInfo={imageFeesInfo} cost={totalCost} parentId={parentId} boost={boost} />
|
||||||
</Info>}
|
</Info>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
.receipt {
|
.receipt {
|
||||||
background-color: var(--theme-inputBg);
|
background-color: var(--theme-inputBg);
|
||||||
max-width: 250px;
|
max-width: 300px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
table-layout: auto;
|
table-layout: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
@ -9,12 +9,12 @@ import Dropdown from 'react-bootstrap/Dropdown'
|
||||||
import Nav from 'react-bootstrap/Nav'
|
import Nav from 'react-bootstrap/Nav'
|
||||||
import Row from 'react-bootstrap/Row'
|
import Row from 'react-bootstrap/Row'
|
||||||
import Markdown from '../svgs/markdown-line.svg'
|
import Markdown from '../svgs/markdown-line.svg'
|
||||||
|
import AddImageIcon from '../svgs/image-add-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'
|
import AddIcon from '../svgs/add-fill.svg'
|
||||||
import { mdHas } from '../lib/md'
|
|
||||||
import CloseIcon from '../svgs/close-line.svg'
|
import CloseIcon from '../svgs/close-line.svg'
|
||||||
import { useLazyQuery } from '@apollo/client'
|
import { gql, useLazyQuery } from '@apollo/client'
|
||||||
import { TOP_USERS, USER_SEARCH } from '../fragments/users'
|
import { TOP_USERS, USER_SEARCH } from '../fragments/users'
|
||||||
import TextareaAutosize from 'react-textarea-autosize'
|
import TextareaAutosize from 'react-textarea-autosize'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
|
@ -24,6 +24,8 @@ import textAreaCaret from 'textarea-caret'
|
||||||
import ReactDatePicker from 'react-datepicker'
|
import ReactDatePicker from 'react-datepicker'
|
||||||
import 'react-datepicker/dist/react-datepicker.css'
|
import 'react-datepicker/dist/react-datepicker.css'
|
||||||
import { debounce } from './use-debounce-callback'
|
import { debounce } from './use-debounce-callback'
|
||||||
|
import { ImageUpload } from './image'
|
||||||
|
import { AWS_S3_URL_REGEXP } from '../lib/constants'
|
||||||
|
|
||||||
export function SubmitButton ({
|
export function SubmitButton ({
|
||||||
children, variant, value, onClick, disabled, cost, ...props
|
children, variant, value, onClick, disabled, cost, ...props
|
||||||
|
@ -93,12 +95,34 @@ export function InputSkeleton ({ label, hint }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MENTION_INDICES = { start: -1, end: -1 }
|
const DEFAULT_MENTION_INDICES = { start: -1, end: -1 }
|
||||||
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, onKeyDown, innerRef, ...props }) {
|
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
|
||||||
const [tab, setTab] = useState('write')
|
const [tab, setTab] = useState('write')
|
||||||
const [, meta, helpers] = useField(props)
|
const [, meta, helpers] = useField(props)
|
||||||
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
|
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
|
||||||
innerRef = innerRef || useRef(null)
|
innerRef = innerRef || useRef(null)
|
||||||
|
const imageUploadRef = useRef(null)
|
||||||
const previousTab = useRef(tab)
|
const previousTab = useRef(tab)
|
||||||
|
const formik = useFormikContext()
|
||||||
|
const toaster = useToast()
|
||||||
|
const [updateImageFeesInfo] = useLazyQuery(gql`
|
||||||
|
query imageFeesInfo($s3Keys: [Int]!) {
|
||||||
|
imageFeesInfo(s3Keys: $s3Keys) {
|
||||||
|
totalFees
|
||||||
|
nUnpaid
|
||||||
|
imageFee
|
||||||
|
bytes24h
|
||||||
|
}
|
||||||
|
}`, {
|
||||||
|
fetchPolicy: 'no-cache',
|
||||||
|
nextFetchPolicy: 'no-cache',
|
||||||
|
onError: (err) => {
|
||||||
|
console.log(err)
|
||||||
|
toaster.danger(err.message || err.toString?.())
|
||||||
|
},
|
||||||
|
onCompleted: ({ imageFeesInfo }) => {
|
||||||
|
formik?.setFieldValue('imageFeesInfo', imageFeesInfo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
props.as ||= TextareaAutosize
|
props.as ||= TextareaAutosize
|
||||||
props.rows ||= props.minRows || 6
|
props.rows ||= props.minRows || 6
|
||||||
|
@ -137,11 +161,14 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||||
innerRef.current.focus()
|
innerRef.current.focus()
|
||||||
}, [mentionIndices, innerRef, helpers?.setValue])
|
}, [mentionIndices, innerRef, helpers?.setValue])
|
||||||
|
|
||||||
|
const imageFeesUpdate = useCallback(debounce(
|
||||||
|
(text) => {
|
||||||
|
const s3Keys = text ? [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) : []
|
||||||
|
updateImageFeesInfo({ variables: { s3Keys } })
|
||||||
|
}, 1000), [debounce, updateImageFeesInfo])
|
||||||
|
|
||||||
const onChangeInner = useCallback((formik, e) => {
|
const onChangeInner = useCallback((formik, e) => {
|
||||||
if (onChange) onChange(formik, e)
|
if (onChange) onChange(formik, e)
|
||||||
if (setHasImgLink) {
|
|
||||||
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
|
|
||||||
}
|
|
||||||
// check for mention editing
|
// check for mention editing
|
||||||
const { value, selectionStart } = e.target
|
const { value, selectionStart } = e.target
|
||||||
let priorSpace = -1
|
let priorSpace = -1
|
||||||
|
@ -174,7 +201,9 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||||
setMentionQuery(undefined)
|
setMentionQuery(undefined)
|
||||||
setMentionIndices(DEFAULT_MENTION_INDICES)
|
setMentionIndices(DEFAULT_MENTION_INDICES)
|
||||||
}
|
}
|
||||||
}, [onChange, setHasImgLink, setMentionQuery, setMentionIndices, setUserSuggestDropdownStyle])
|
|
||||||
|
imageFeesUpdate(value)
|
||||||
|
}, [onChange, setMentionQuery, setMentionIndices, setUserSuggestDropdownStyle, imageFeesUpdate])
|
||||||
|
|
||||||
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
|
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
|
||||||
return (e) => {
|
return (e) => {
|
||||||
|
@ -209,6 +238,22 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||||
}
|
}
|
||||||
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown])
|
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown])
|
||||||
|
|
||||||
|
const onDrop = useCallback((event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setDragStyle(null)
|
||||||
|
const changeEvent = new Event('change', { bubbles: true })
|
||||||
|
imageUploadRef.current.files = event.dataTransfer.files
|
||||||
|
imageUploadRef.current.dispatchEvent(changeEvent)
|
||||||
|
}, [imageUploadRef])
|
||||||
|
|
||||||
|
const [dragStyle, setDragStyle] = useState(null)
|
||||||
|
const onDragEnter = useCallback((e) => {
|
||||||
|
setDragStyle('over')
|
||||||
|
}, [setDragStyle])
|
||||||
|
const onDragLeave = useCallback((e) => {
|
||||||
|
setDragStyle(null)
|
||||||
|
}, [setDragStyle])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup label={label} className={groupClassName}>
|
<FormGroup label={label} className={groupClassName}>
|
||||||
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
|
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
|
||||||
|
@ -219,12 +264,39 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
<Nav.Link className={styles.previewTab} eventKey='preview' disabled={!meta.value}>preview</Nav.Link>
|
<Nav.Link className={styles.previewTab} eventKey='preview' disabled={!meta.value}>preview</Nav.Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<a
|
<span className='ms-auto text-muted d-flex align-items-center'>
|
||||||
className='ms-auto text-muted d-flex align-items-center'
|
<ImageUpload
|
||||||
href='https://guides.github.com/features/mastering-markdown/' target='_blank' rel='noreferrer'
|
multiple
|
||||||
>
|
ref={imageUploadRef}
|
||||||
<Markdown width={18} height={18} />
|
className='d-flex align-items-center me-1'
|
||||||
</a>
|
onUpload={file => {
|
||||||
|
let text = innerRef.current.value
|
||||||
|
if (text) text += '\n\n'
|
||||||
|
text += `![Uploading ${file.name}…]()`
|
||||||
|
helpers.setValue(text)
|
||||||
|
}}
|
||||||
|
onSuccess={({ url, name }) => {
|
||||||
|
let text = innerRef.current.value
|
||||||
|
text = text.replace(`![Uploading ${name}…]()`, `![${name}](${url})`)
|
||||||
|
helpers.setValue(text)
|
||||||
|
const s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
|
||||||
|
updateImageFeesInfo({ variables: { s3Keys } })
|
||||||
|
}}
|
||||||
|
onError={({ name }) => {
|
||||||
|
let text = innerRef.current.value
|
||||||
|
text = text.replace(`![Uploading ${name}…]()`, '')
|
||||||
|
helpers.setValue(text)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AddImageIcon width={18} height={18} />
|
||||||
|
</ImageUpload>
|
||||||
|
<a
|
||||||
|
className='d-flex align-items-center'
|
||||||
|
href='https://guides.github.com/features/mastering-markdown/' target='_blank' rel='noreferrer'
|
||||||
|
>
|
||||||
|
<Markdown width={18} height={18} />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
</Nav>
|
</Nav>
|
||||||
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
|
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
|
||||||
<UserSuggest
|
<UserSuggest
|
||||||
|
@ -238,6 +310,10 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||||
onChange={onChangeInner}
|
onChange={onChangeInner}
|
||||||
onKeyDown={onKeyDownInner(userSuggestOnKeyDown)}
|
onKeyDown={onKeyDownInner(userSuggestOnKeyDown)}
|
||||||
onBlur={() => setTimeout(resetSuggestions, 100)}
|
onBlur={() => setTimeout(resetSuggestions, 100)}
|
||||||
|
onDragEnter={onDragEnter}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={onDrop}
|
||||||
|
className={dragStyle === 'over' ? styles.dragOver : ''}
|
||||||
/>)}
|
/>)}
|
||||||
</UserSuggest>
|
</UserSuggest>
|
||||||
</div>
|
</div>
|
||||||
|
@ -675,6 +751,14 @@ export function Form ({
|
||||||
const onSubmitInner = useCallback(async (values, ...args) => {
|
const onSubmitInner = useCallback(async (values, ...args) => {
|
||||||
try {
|
try {
|
||||||
if (onSubmit) {
|
if (onSubmit) {
|
||||||
|
// extract cost from formik fields
|
||||||
|
// (cost may also be set in a formik field named 'amount')
|
||||||
|
let cost = values?.cost || values?.amount
|
||||||
|
// add potential image fees which are set in a different field
|
||||||
|
// to differentiate between fees (in receipts for example)
|
||||||
|
cost += (values?.imageFeesInfo?.totalFees || 0)
|
||||||
|
values.cost = cost
|
||||||
|
|
||||||
const options = await onSubmit(values, ...args)
|
const options = await onSubmit(values, ...args)
|
||||||
if (!storageKeyPrefix || options?.keepLocalStorage) return
|
if (!storageKeyPrefix || options?.keepLocalStorage) return
|
||||||
clearLocalStorage(values)
|
clearLocalStorage(values)
|
||||||
|
|
|
@ -19,6 +19,10 @@
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dragOver {
|
||||||
|
box-shadow: 0 0 10px var(--bs-info);
|
||||||
|
}
|
||||||
|
|
||||||
.appendButton {
|
.appendButton {
|
||||||
border-left: 0 !important;
|
border-left: 0 !important;
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
import styles from './text.module.css'
|
import styles from './text.module.css'
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
import { Fragment, useState, useEffect, useMemo, useCallback, forwardRef, useRef } from 'react'
|
||||||
import { IMGPROXY_URL_REGEXP } from '../lib/url'
|
import { IMGPROXY_URL_REGEXP } from '../lib/url'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { Dropdown } from 'react-bootstrap'
|
import { Dropdown } from 'react-bootstrap'
|
||||||
|
import { UPLOAD_TYPES_ALLOW } from '../lib/constants'
|
||||||
|
import { useToast } from './toast'
|
||||||
|
import gql from 'graphql-tag'
|
||||||
|
import { useMutation } from '@apollo/client'
|
||||||
|
|
||||||
export function decodeOriginalUrl (imgproxyUrl) {
|
export function decodeOriginalUrl (imgproxyUrl) {
|
||||||
const parts = imgproxyUrl.split('/')
|
const parts = imgproxyUrl.split('/')
|
||||||
|
@ -132,3 +136,99 @@ export default function ZoomableImage ({ src, srcSet, ...props }) {
|
||||||
|
|
||||||
return <ImageOriginal src={originalUrl} onClick={handleClick} {...props} />
|
return <ImageOriginal src={originalUrl} onClick={handleClick} {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload, onSuccess, onError, multiple, avatar }, ref) => {
|
||||||
|
const toaster = useToast()
|
||||||
|
ref ??= useRef(null)
|
||||||
|
|
||||||
|
const [getSignedPOST] = useMutation(
|
||||||
|
gql`
|
||||||
|
mutation getSignedPOST($type: String!, $size: Int!, $width: Int!, $height: Int!, $avatar: Boolean) {
|
||||||
|
getSignedPOST(type: $type, size: $size, width: $width, height: $height, avatar: $avatar) {
|
||||||
|
url
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
const s3Upload = useCallback(file => {
|
||||||
|
const img = new window.Image()
|
||||||
|
img.src = window.URL.createObjectURL(file)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
img.onload = async () => {
|
||||||
|
onUpload?.(file)
|
||||||
|
let data
|
||||||
|
const variables = {
|
||||||
|
avatar,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
width: img.width,
|
||||||
|
height: img.height
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
({ data } = await getSignedPOST({ variables }))
|
||||||
|
} catch (e) {
|
||||||
|
toaster.danger(e.message || e.toString?.())
|
||||||
|
onError?.({ ...variables, name: file.name, file })
|
||||||
|
reject(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = new FormData()
|
||||||
|
Object.keys(data.getSignedPOST.fields).forEach(key => form.append(key, data.getSignedPOST.fields[key]))
|
||||||
|
form.append('Content-Type', file.type)
|
||||||
|
form.append('Cache-Control', 'max-age=31536000')
|
||||||
|
form.append('acl', 'public-read')
|
||||||
|
form.append('file', file)
|
||||||
|
|
||||||
|
const res = await fetch(data.getSignedPOST.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
// TODO make sure this is actually a helpful error message and does not expose anything to the user we don't want
|
||||||
|
const err = res.statusText
|
||||||
|
toaster.danger(err)
|
||||||
|
onError?.({ ...variables, name: file.name, file })
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${data.getSignedPOST.fields.key}`
|
||||||
|
// key is upload id in database
|
||||||
|
const id = data.getSignedPOST.fields.key
|
||||||
|
onSuccess?.({ ...variables, id, name: file.name, url, file })
|
||||||
|
resolve(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [toaster, getSignedPOST])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type='file'
|
||||||
|
multiple={multiple}
|
||||||
|
className='d-none'
|
||||||
|
accept={UPLOAD_TYPES_ALLOW.join(', ')}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const fileList = e.target.files
|
||||||
|
for (const file of Array.from(fileList)) {
|
||||||
|
if (UPLOAD_TYPES_ALLOW.indexOf(file.type) === -1) {
|
||||||
|
toaster.danger(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (onSelect) await onSelect?.(file, s3Upload)
|
||||||
|
else await s3Upload(file)
|
||||||
|
// reset file input
|
||||||
|
// see https://bobbyhadz.com/blog/react-reset-file-input#reset-a-file-input-in-react
|
||||||
|
e.target.value = null
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={className} onClick={() => ref.current?.click()} style={{ cursor: 'pointer' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
|
@ -231,10 +231,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
||||||
|
|
||||||
// this function will be called before the Form's onSubmit handler is called
|
// this function will be called before the Form's onSubmit handler is called
|
||||||
// and the form must include `cost` or `amount` as a value
|
// and the form must include `cost` or `amount` as a value
|
||||||
const onSubmitWrapper = useCallback(async (formValues, ...submitArgs) => {
|
const onSubmitWrapper = useCallback(async ({ cost, ...formValues }, ...submitArgs) => {
|
||||||
let { cost, amount } = formValues
|
|
||||||
cost ??= amount
|
|
||||||
|
|
||||||
// action only allowed if logged in
|
// action only allowed if logged in
|
||||||
if (!me && options.requireSession) {
|
if (!me && options.requireSession) {
|
||||||
throw new Error('you must be logged in')
|
throw new Error('you must be logged in')
|
||||||
|
|
|
@ -25,7 +25,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
||||||
<div className={styles.item}>
|
<div className={styles.item}>
|
||||||
<Link href={`/items/${item.id}`}>
|
<Link href={`/items/${item.id}`}>
|
||||||
<Image
|
<Image
|
||||||
src={item.uploadId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${item.uploadId}` : '/jobs-default.png'} width='42' height='42' className={styles.companyImage}
|
src={item.uploadId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${item.uploadId}` : '/jobs-default.png'} width='42' height='42' className={styles.companyImage}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className={`${styles.hunk} align-self-center mb-0`}>
|
<div className={`${styles.hunk} align-self-center mb-0`}>
|
||||||
|
|
|
@ -106,7 +106,7 @@ export default function JobForm ({ item, sub }) {
|
||||||
<label className='form-label'>logo</label>
|
<label className='form-label'>logo</label>
|
||||||
<div className='position-relative' style={{ width: 'fit-content' }}>
|
<div className='position-relative' style={{ width: 'fit-content' }}>
|
||||||
<Image
|
<Image
|
||||||
src={logoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${logoId}` : '/jobs-default.png'} width='135' height='135' roundedCircle
|
src={logoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${logoId}` : '/jobs-default.png'} width='135' height='135' roundedCircle
|
||||||
/>
|
/>
|
||||||
<Avatar onSuccess={setLogoId} />
|
<Avatar onSuccess={setLogoId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,90 +0,0 @@
|
||||||
import { useRef } from 'react'
|
|
||||||
import { gql, useMutation } from '@apollo/client'
|
|
||||||
import { UPLOAD_TYPES_ALLOW } from '../lib/constants'
|
|
||||||
|
|
||||||
export default function Upload ({ as: Component, onSelect, onStarted, onError, onSuccess }) {
|
|
||||||
const [getSignedPOST] = useMutation(
|
|
||||||
gql`
|
|
||||||
mutation getSignedPOST($type: String!, $size: Int!, $width: Int!, $height: Int!) {
|
|
||||||
getSignedPOST(type: $type, size: $size, width: $width, height: $height) {
|
|
||||||
url
|
|
||||||
fields
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
const ref = useRef()
|
|
||||||
|
|
||||||
const upload = file => {
|
|
||||||
onStarted && onStarted()
|
|
||||||
|
|
||||||
const img = new window.Image()
|
|
||||||
img.src = window.URL.createObjectURL(file)
|
|
||||||
img.onload = async () => {
|
|
||||||
let data
|
|
||||||
try {
|
|
||||||
({ data } = await getSignedPOST({
|
|
||||||
variables: {
|
|
||||||
type: file.type,
|
|
||||||
size: file.size,
|
|
||||||
width: img.width,
|
|
||||||
height: img.height
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
} catch (e) {
|
|
||||||
onError && onError(e.toString())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = new FormData()
|
|
||||||
Object.keys(data.getSignedPOST.fields).forEach(key =>
|
|
||||||
form.append(key, data.getSignedPOST.fields[key]))
|
|
||||||
form.append('Content-Type', file.type)
|
|
||||||
form.append('Cache-Control', 'max-age=31536000')
|
|
||||||
form.append('acl', 'public-read')
|
|
||||||
form.append('file', file)
|
|
||||||
|
|
||||||
const res = await fetch(data.getSignedPOST.url, {
|
|
||||||
method: 'POST',
|
|
||||||
body: form
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
onError && onError(res.statusText)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onSuccess && onSuccess(data.getSignedPOST.fields.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
type='file'
|
|
||||||
className='d-none'
|
|
||||||
accept={UPLOAD_TYPES_ALLOW.join(', ')}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.files.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = e.target.files[0]
|
|
||||||
|
|
||||||
if (UPLOAD_TYPES_ALLOW.indexOf(file.type) === -1) {
|
|
||||||
onError && onError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onSelect) {
|
|
||||||
onSelect(file, upload)
|
|
||||||
} else {
|
|
||||||
upload(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
e.target.value = null
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Component onClick={() => ref.current?.click()} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -71,14 +71,20 @@ function HeaderPhoto ({ user, isMe }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
onCompleted ({ setPhoto: photoId }) {
|
||||||
|
const src = `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}`
|
||||||
|
setSrc(src)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
const initialSrc = user.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}` : '/dorian400.jpg'
|
||||||
|
const [src, setSrc] = useState(initialSrc)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='position-relative align-self-start' style={{ width: 'fit-content' }}>
|
<div className='position-relative align-self-start' style={{ width: 'fit-content' }}>
|
||||||
<Image
|
<Image
|
||||||
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='135' height='135'
|
src={src} width='135' height='135'
|
||||||
className={styles.userimg}
|
className={styles.userimg}
|
||||||
/>
|
/>
|
||||||
{isMe &&
|
{isMe &&
|
||||||
|
|
|
@ -66,7 +66,7 @@ export default function UserList ({ ssrData, query, variables, destructureData }
|
||||||
<div className={`${styles.item} mb-2`} key={user.name}>
|
<div className={`${styles.item} mb-2`} key={user.name}>
|
||||||
<Link href={`/${user.name}`}>
|
<Link href={`/${user.name}`}>
|
||||||
<Image
|
<Image
|
||||||
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
|
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
|
||||||
className={`${userStyles.userimg} me-2`}
|
className={`${userStyles.userimg} me-2`}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -6,8 +6,10 @@ export const SUBS_NO_JOBS = SUBS.filter(s => s !== 'jobs')
|
||||||
export const NOFOLLOW_LIMIT = 1000
|
export const NOFOLLOW_LIMIT = 1000
|
||||||
export const BOOST_MULT = 5000
|
export const BOOST_MULT = 5000
|
||||||
export const BOOST_MIN = BOOST_MULT * 5
|
export const BOOST_MIN = BOOST_MULT * 5
|
||||||
export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024
|
export const UPLOAD_SIZE_MAX = 25 * 1024 * 1024
|
||||||
|
export const UPLOAD_SIZE_MAX_AVATAR = 5 * 1024 * 1024
|
||||||
export const IMAGE_PIXELS_MAX = 35000000
|
export const IMAGE_PIXELS_MAX = 35000000
|
||||||
|
export const AWS_S3_URL_REGEXP = new RegExp(`https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/([0-9]+)`, 'g')
|
||||||
export const UPLOAD_TYPES_ALLOW = [
|
export const UPLOAD_TYPES_ALLOW = [
|
||||||
'image/gif',
|
'image/gif',
|
||||||
'image/heic',
|
'image/heic',
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `itemId` on the `Upload` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Upload" DROP CONSTRAINT "Upload_itemId_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Upload.itemId_index";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Upload.itemId_unique";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Upload" DROP COLUMN "itemId",
|
||||||
|
ADD COLUMN "paid" BOOLEAN;
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- add 'deleteUnusedImages' job
|
||||||
|
CREATE OR REPLACE FUNCTION create_delete_unused_images_job()
|
||||||
|
RETURNS INTEGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO pgboss.schedule (name, cron, timezone) VALUES ('deleteUnusedImages', '0 * * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
|
||||||
|
return 0;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
return 0;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
SELECT create_delete_unused_images_job();
|
||||||
|
|
||||||
|
DROP FUNCTION create_delete_unused_images_job;
|
|
@ -0,0 +1,241 @@
|
||||||
|
-- function to calculate image fees info for given user and upload ids
|
||||||
|
CREATE OR REPLACE FUNCTION image_fees_info(user_id INTEGER, upload_ids INTEGER[])
|
||||||
|
RETURNS TABLE (
|
||||||
|
"bytes24h" INTEGER,
|
||||||
|
"bytesUnpaid" INTEGER,
|
||||||
|
"nUnpaid" INTEGER,
|
||||||
|
"imageFeeMsats" BIGINT
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY SELECT
|
||||||
|
uploadinfo.*,
|
||||||
|
CASE
|
||||||
|
-- anons always pay 100 sats per image
|
||||||
|
WHEN user_id = 27 THEN 100000::BIGINT
|
||||||
|
ELSE CASE
|
||||||
|
-- 10 MB are free per stacker and 24 hours
|
||||||
|
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 10 * 1024 * 1024 THEN 0::BIGINT
|
||||||
|
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 25 * 1024 * 1024 THEN 10000::BIGINT
|
||||||
|
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 50 * 1024 * 1024 THEN 100000::BIGINT
|
||||||
|
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 100 * 1024 * 1024 THEN 1000000::BIGINT
|
||||||
|
ELSE 10000000::BIGINT
|
||||||
|
END
|
||||||
|
END AS "imageFeeMsats"
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
-- how much bytes did stacker upload in last 24 hours?
|
||||||
|
COALESCE(SUM(size) FILTER(WHERE paid = 't' AND created_at >= NOW() - interval '24 hours'), 0)::INTEGER AS "bytes24h",
|
||||||
|
-- how much unpaid bytes do they want to upload now?
|
||||||
|
COALESCE(SUM(size) FILTER(WHERE paid = 'f' AND id = ANY(upload_ids)), 0)::INTEGER AS "bytesUnpaid",
|
||||||
|
-- how many unpaid images do they want to upload now?
|
||||||
|
COALESCE(COUNT(id) FILTER(WHERE paid = 'f' AND id = ANY(upload_ids)), 0)::INTEGER AS "nUnpaid"
|
||||||
|
FROM "Upload"
|
||||||
|
WHERE "Upload"."userId" = user_id
|
||||||
|
) uploadinfo;
|
||||||
|
RETURN;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- add image fees
|
||||||
|
CREATE OR REPLACE FUNCTION create_item(
|
||||||
|
jitem JSONB, forward JSONB, poll_options JSONB, spam_within INTERVAL, upload_ids INTEGER[])
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_msats BIGINT;
|
||||||
|
cost_msats BIGINT;
|
||||||
|
freebie BOOLEAN;
|
||||||
|
item "Item";
|
||||||
|
med_votes FLOAT;
|
||||||
|
select_clause TEXT;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
-- access fields with appropriate types
|
||||||
|
item := jsonb_populate_record(NULL::"Item", jitem);
|
||||||
|
|
||||||
|
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
|
||||||
|
|
||||||
|
IF item."maxBid" IS NOT NULL THEN
|
||||||
|
cost_msats := 1000000;
|
||||||
|
ELSE
|
||||||
|
cost_msats := 1000 * POWER(10, item_spam(item."parentId", item."userId", spam_within));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- add image fees
|
||||||
|
IF upload_ids IS NOT NULL THEN
|
||||||
|
cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids));
|
||||||
|
UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- it's only a freebie if it's a 1 sat cost, they have < 1 sat, and boost = 0
|
||||||
|
freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (item.boost IS NULL OR item.boost = 0);
|
||||||
|
|
||||||
|
IF NOT freebie AND cost_msats > user_msats THEN
|
||||||
|
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- get this user's median item score
|
||||||
|
SELECT COALESCE(
|
||||||
|
percentile_cont(0.5) WITHIN GROUP(
|
||||||
|
ORDER BY "weightedVotes" - "weightedDownVotes"), 0)
|
||||||
|
INTO med_votes FROM "Item" WHERE "userId" = item."userId";
|
||||||
|
|
||||||
|
-- if their median votes are positive, start at 0
|
||||||
|
-- if the median votes are negative, start their post with that many down votes
|
||||||
|
-- basically: if their median post is bad, presume this post is too
|
||||||
|
-- addendum: if they're an anon poster, always start at 0
|
||||||
|
IF med_votes >= 0 OR item."userId" = 27 THEN
|
||||||
|
med_votes := 0;
|
||||||
|
ELSE
|
||||||
|
med_votes := ABS(med_votes);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- there's no great way to set default column values when using json_populate_record
|
||||||
|
-- so we need to only select fields with non-null values that way when func input
|
||||||
|
-- does not include a value, the default value is used instead of null
|
||||||
|
SELECT string_agg(quote_ident(key), ',') INTO select_clause
|
||||||
|
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key);
|
||||||
|
-- insert the item
|
||||||
|
EXECUTE format($fmt$
|
||||||
|
INSERT INTO "Item" (%s, "weightedDownVotes", freebie)
|
||||||
|
SELECT %1$s, %L, %L
|
||||||
|
FROM jsonb_populate_record(NULL::"Item", %L) RETURNING *
|
||||||
|
$fmt$, select_clause, med_votes, freebie, jitem) INTO item;
|
||||||
|
|
||||||
|
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
|
||||||
|
SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
|
||||||
|
|
||||||
|
-- Automatically subscribe to one's own posts
|
||||||
|
INSERT INTO "ThreadSubscription" ("itemId", "userId")
|
||||||
|
VALUES (item.id, item."userId");
|
||||||
|
|
||||||
|
-- Automatically subscribe forward recipients to the new post
|
||||||
|
INSERT INTO "ThreadSubscription" ("itemId", "userId")
|
||||||
|
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
|
||||||
|
|
||||||
|
INSERT INTO "PollOption" ("itemId", "option")
|
||||||
|
SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option");
|
||||||
|
|
||||||
|
IF NOT freebie THEN
|
||||||
|
UPDATE users SET msats = msats - cost_msats WHERE id = item."userId";
|
||||||
|
|
||||||
|
INSERT INTO "ItemAct" (msats, "itemId", "userId", act)
|
||||||
|
VALUES (cost_msats, item.id, item."userId", 'FEE');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- if this item has boost
|
||||||
|
IF item.boost > 0 THEN
|
||||||
|
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- if this is a job
|
||||||
|
IF item."maxBid" IS NOT NULL THEN
|
||||||
|
PERFORM run_auction(item.id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- if this is a bio
|
||||||
|
IF item.bio THEN
|
||||||
|
UPDATE users SET "bioId" = item.id WHERE id = item."userId";
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- schedule imgproxy job
|
||||||
|
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
|
||||||
|
VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds');
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- add image fees
|
||||||
|
CREATE OR REPLACE FUNCTION update_item(
|
||||||
|
jitem JSONB, forward JSONB, poll_options JSONB, upload_ids INTEGER[])
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_msats INTEGER;
|
||||||
|
cost_msats BIGINT;
|
||||||
|
item "Item";
|
||||||
|
select_clause TEXT;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
item := jsonb_populate_record(NULL::"Item", jitem);
|
||||||
|
|
||||||
|
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
|
||||||
|
cost_msats := 0;
|
||||||
|
|
||||||
|
-- add image fees
|
||||||
|
IF upload_ids IS NOT NULL THEN
|
||||||
|
cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids));
|
||||||
|
UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF cost_msats > 0 AND cost_msats > user_msats THEN
|
||||||
|
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||||
|
ELSE
|
||||||
|
UPDATE users SET msats = msats - cost_msats WHERE id = item."userId";
|
||||||
|
INSERT INTO "ItemAct" (msats, "itemId", "userId", act)
|
||||||
|
VALUES (cost_msats, item.id, item."userId", 'FEE');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF item.boost > 0 THEN
|
||||||
|
UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id;
|
||||||
|
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF item.status IS NOT NULL THEN
|
||||||
|
UPDATE "Item" SET "statusUpdatedAt" = now_utc()
|
||||||
|
WHERE id = item.id AND status <> item.status;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT string_agg(quote_ident(key), ',') INTO select_clause
|
||||||
|
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key)
|
||||||
|
WHERE key <> 'boost';
|
||||||
|
|
||||||
|
EXECUTE format($fmt$
|
||||||
|
UPDATE "Item" SET (%s) = (
|
||||||
|
SELECT %1$s
|
||||||
|
FROM jsonb_populate_record(NULL::"Item", %L)
|
||||||
|
) WHERE id = %L RETURNING *
|
||||||
|
$fmt$, select_clause, jitem, item.id) INTO item;
|
||||||
|
|
||||||
|
-- Delete any old thread subs if the user is no longer a fwd recipient
|
||||||
|
DELETE FROM "ThreadSubscription"
|
||||||
|
WHERE "itemId" = item.id
|
||||||
|
-- they aren't in the new forward list
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM jsonb_populate_recordset(NULL::"ItemForward", forward) as nf WHERE "ThreadSubscription"."userId" = nf."userId")
|
||||||
|
-- and they are in the old forward list
|
||||||
|
AND EXISTS (SELECT 1 FROM "ItemForward" WHERE "ItemForward"."itemId" = item.id AND "ItemForward"."userId" = "ThreadSubscription"."userId" );
|
||||||
|
|
||||||
|
-- Automatically subscribe any new forward recipients to the post
|
||||||
|
INSERT INTO "ThreadSubscription" ("itemId", "userId")
|
||||||
|
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward)
|
||||||
|
EXCEPT
|
||||||
|
SELECT item.id, "userId" FROM "ItemForward" WHERE "itemId" = item.id;
|
||||||
|
|
||||||
|
-- Delete all old forward entries, to recreate in next command
|
||||||
|
DELETE FROM "ItemForward" WHERE "itemId" = item.id;
|
||||||
|
|
||||||
|
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
|
||||||
|
SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
|
||||||
|
|
||||||
|
INSERT INTO "PollOption" ("itemId", "option")
|
||||||
|
SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option");
|
||||||
|
|
||||||
|
-- if this is a job
|
||||||
|
IF item."maxBid" IS NOT NULL THEN
|
||||||
|
PERFORM run_auction(item.id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- schedule imgproxy job
|
||||||
|
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
|
||||||
|
VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds');
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
|
@ -0,0 +1,237 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ItemUpload" (
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"itemId" INTEGER NOT NULL,
|
||||||
|
"uploadId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ItemUpload_pkey" PRIMARY KEY ("itemId","uploadId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ItemUpload_created_at_idx" ON "ItemUpload"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ItemUpload_itemId_idx" ON "ItemUpload"("itemId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ItemUpload_uploadId_idx" ON "ItemUpload"("uploadId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ItemUpload" ADD CONSTRAINT "ItemUpload_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ItemUpload" ADD CONSTRAINT "ItemUpload_uploadId_fkey" FOREIGN KEY ("uploadId") REFERENCES "Upload"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- add image fees
|
||||||
|
CREATE OR REPLACE FUNCTION create_item(
|
||||||
|
jitem JSONB, forward JSONB, poll_options JSONB, spam_within INTERVAL, upload_ids INTEGER[])
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_msats BIGINT;
|
||||||
|
cost_msats BIGINT;
|
||||||
|
freebie BOOLEAN;
|
||||||
|
item "Item";
|
||||||
|
med_votes FLOAT;
|
||||||
|
select_clause TEXT;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
-- access fields with appropriate types
|
||||||
|
item := jsonb_populate_record(NULL::"Item", jitem);
|
||||||
|
|
||||||
|
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
|
||||||
|
|
||||||
|
IF item."maxBid" IS NOT NULL THEN
|
||||||
|
cost_msats := 1000000;
|
||||||
|
ELSE
|
||||||
|
cost_msats := 1000 * POWER(10, item_spam(item."parentId", item."userId", spam_within));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- add image fees
|
||||||
|
IF upload_ids IS NOT NULL THEN
|
||||||
|
cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids));
|
||||||
|
UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- it's only a freebie if it's a 1 sat cost, they have < 1 sat, and boost = 0
|
||||||
|
freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (item.boost IS NULL OR item.boost = 0);
|
||||||
|
|
||||||
|
IF NOT freebie AND cost_msats > user_msats THEN
|
||||||
|
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- get this user's median item score
|
||||||
|
SELECT COALESCE(
|
||||||
|
percentile_cont(0.5) WITHIN GROUP(
|
||||||
|
ORDER BY "weightedVotes" - "weightedDownVotes"), 0)
|
||||||
|
INTO med_votes FROM "Item" WHERE "userId" = item."userId";
|
||||||
|
|
||||||
|
-- if their median votes are positive, start at 0
|
||||||
|
-- if the median votes are negative, start their post with that many down votes
|
||||||
|
-- basically: if their median post is bad, presume this post is too
|
||||||
|
-- addendum: if they're an anon poster, always start at 0
|
||||||
|
IF med_votes >= 0 OR item."userId" = 27 THEN
|
||||||
|
med_votes := 0;
|
||||||
|
ELSE
|
||||||
|
med_votes := ABS(med_votes);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- there's no great way to set default column values when using json_populate_record
|
||||||
|
-- so we need to only select fields with non-null values that way when func input
|
||||||
|
-- does not include a value, the default value is used instead of null
|
||||||
|
SELECT string_agg(quote_ident(key), ',') INTO select_clause
|
||||||
|
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key);
|
||||||
|
-- insert the item
|
||||||
|
EXECUTE format($fmt$
|
||||||
|
INSERT INTO "Item" (%s, "weightedDownVotes", freebie)
|
||||||
|
SELECT %1$s, %L, %L
|
||||||
|
FROM jsonb_populate_record(NULL::"Item", %L) RETURNING *
|
||||||
|
$fmt$, select_clause, med_votes, freebie, jitem) INTO item;
|
||||||
|
|
||||||
|
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
|
||||||
|
SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
|
||||||
|
|
||||||
|
-- Automatically subscribe to one's own posts
|
||||||
|
INSERT INTO "ThreadSubscription" ("itemId", "userId")
|
||||||
|
VALUES (item.id, item."userId");
|
||||||
|
|
||||||
|
-- Automatically subscribe forward recipients to the new post
|
||||||
|
INSERT INTO "ThreadSubscription" ("itemId", "userId")
|
||||||
|
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
|
||||||
|
|
||||||
|
INSERT INTO "PollOption" ("itemId", "option")
|
||||||
|
SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option");
|
||||||
|
|
||||||
|
IF NOT freebie THEN
|
||||||
|
UPDATE users SET msats = msats - cost_msats WHERE id = item."userId";
|
||||||
|
|
||||||
|
INSERT INTO "ItemAct" (msats, "itemId", "userId", act)
|
||||||
|
VALUES (cost_msats, item.id, item."userId", 'FEE');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- if this item has boost
|
||||||
|
IF item.boost > 0 THEN
|
||||||
|
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- if this is a job
|
||||||
|
IF item."maxBid" IS NOT NULL THEN
|
||||||
|
PERFORM run_auction(item.id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- if this is a bio
|
||||||
|
IF item.bio THEN
|
||||||
|
UPDATE users SET "bioId" = item.id WHERE id = item."userId";
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- record attachments
|
||||||
|
IF upload_ids IS NOT NULL THEN
|
||||||
|
INSERT INTO "ItemUpload" ("itemId", "uploadId")
|
||||||
|
SELECT item.id, * FROM UNNEST(upload_ids);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- schedule imgproxy job
|
||||||
|
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
|
||||||
|
VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds');
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- add image fees
|
||||||
|
CREATE OR REPLACE FUNCTION update_item(
|
||||||
|
jitem JSONB, forward JSONB, poll_options JSONB, upload_ids INTEGER[])
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_msats INTEGER;
|
||||||
|
cost_msats BIGINT;
|
||||||
|
item "Item";
|
||||||
|
select_clause TEXT;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
item := jsonb_populate_record(NULL::"Item", jitem);
|
||||||
|
|
||||||
|
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
|
||||||
|
cost_msats := 0;
|
||||||
|
|
||||||
|
-- add image fees
|
||||||
|
IF upload_ids IS NOT NULL THEN
|
||||||
|
cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids));
|
||||||
|
UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids);
|
||||||
|
-- delete any old uploads that are no longer attached
|
||||||
|
DELETE FROM "ItemUpload" WHERE "itemId" = item.id AND "uploadId" <> ANY(upload_ids);
|
||||||
|
-- insert any new uploads that are not already attached
|
||||||
|
INSERT INTO "ItemUpload" ("itemId", "uploadId")
|
||||||
|
SELECT item.id, * FROM UNNEST(upload_ids) ON CONFLICT DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF cost_msats > 0 AND cost_msats > user_msats THEN
|
||||||
|
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||||
|
ELSE
|
||||||
|
UPDATE users SET msats = msats - cost_msats WHERE id = item."userId";
|
||||||
|
INSERT INTO "ItemAct" (msats, "itemId", "userId", act)
|
||||||
|
VALUES (cost_msats, item.id, item."userId", 'FEE');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF item.boost > 0 THEN
|
||||||
|
UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id;
|
||||||
|
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF item.status IS NOT NULL THEN
|
||||||
|
UPDATE "Item" SET "statusUpdatedAt" = now_utc()
|
||||||
|
WHERE id = item.id AND status <> item.status;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT string_agg(quote_ident(key), ',') INTO select_clause
|
||||||
|
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key)
|
||||||
|
WHERE key <> 'boost';
|
||||||
|
|
||||||
|
EXECUTE format($fmt$
|
||||||
|
UPDATE "Item" SET (%s) = (
|
||||||
|
SELECT %1$s
|
||||||
|
FROM jsonb_populate_record(NULL::"Item", %L)
|
||||||
|
) WHERE id = %L RETURNING *
|
||||||
|
$fmt$, select_clause, jitem, item.id) INTO item;
|
||||||
|
|
||||||
|
-- Delete any old thread subs if the user is no longer a fwd recipient
|
||||||
|
DELETE FROM "ThreadSubscription"
|
||||||
|
WHERE "itemId" = item.id
|
||||||
|
-- they aren't in the new forward list
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM jsonb_populate_recordset(NULL::"ItemForward", forward) as nf WHERE "ThreadSubscription"."userId" = nf."userId")
|
||||||
|
-- and they are in the old forward list
|
||||||
|
AND EXISTS (SELECT 1 FROM "ItemForward" WHERE "ItemForward"."itemId" = item.id AND "ItemForward"."userId" = "ThreadSubscription"."userId" );
|
||||||
|
|
||||||
|
-- Automatically subscribe any new forward recipients to the post
|
||||||
|
INSERT INTO "ThreadSubscription" ("itemId", "userId")
|
||||||
|
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward)
|
||||||
|
EXCEPT
|
||||||
|
SELECT item.id, "userId" FROM "ItemForward" WHERE "itemId" = item.id;
|
||||||
|
|
||||||
|
-- Delete all old forward entries, to recreate in next command
|
||||||
|
DELETE FROM "ItemForward" WHERE "itemId" = item.id;
|
||||||
|
|
||||||
|
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
|
||||||
|
SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
|
||||||
|
|
||||||
|
INSERT INTO "PollOption" ("itemId", "option")
|
||||||
|
SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option");
|
||||||
|
|
||||||
|
-- if this is a job
|
||||||
|
IF item."maxBid" IS NOT NULL THEN
|
||||||
|
PERFORM run_auction(item.id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- schedule imgproxy job
|
||||||
|
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
|
||||||
|
VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds');
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Item_uploadId_idx" ON "Item"("uploadId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "users_photoId_idx" ON "users"("photoId");
|
|
@ -100,6 +100,7 @@ model User {
|
||||||
ArcOut Arc[] @relation("fromUser")
|
ArcOut Arc[] @relation("fromUser")
|
||||||
ArcIn Arc[] @relation("toUser")
|
ArcIn Arc[] @relation("toUser")
|
||||||
|
|
||||||
|
@@index([photoId])
|
||||||
@@index([createdAt], map: "users.created_at_index")
|
@@index([createdAt], map: "users.created_at_index")
|
||||||
@@index([inviteId], map: "users.inviteId_index")
|
@@index([inviteId], map: "users.inviteId_index")
|
||||||
@@map("users")
|
@@map("users")
|
||||||
|
@ -168,22 +169,35 @@ model Donation {
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Upload {
|
model ItemUpload {
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
type String
|
itemId Int
|
||||||
size Int
|
uploadId Int
|
||||||
width Int?
|
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||||
height Int?
|
upload Upload @relation(fields: [uploadId], references: [id], onDelete: Cascade)
|
||||||
itemId Int? @unique(map: "Upload.itemId_unique")
|
|
||||||
userId Int
|
@@id([itemId, uploadId])
|
||||||
item Item? @relation(fields: [itemId], references: [id])
|
@@index([createdAt])
|
||||||
user User @relation("Uploads", fields: [userId], references: [id], onDelete: Cascade)
|
@@index([itemId])
|
||||||
User User[]
|
@@index([uploadId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Upload {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
|
type String
|
||||||
|
size Int
|
||||||
|
width Int?
|
||||||
|
height Int?
|
||||||
|
userId Int
|
||||||
|
paid Boolean?
|
||||||
|
user User @relation("Uploads", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
User User[]
|
||||||
|
ItemUpload ItemUpload[]
|
||||||
|
|
||||||
@@index([createdAt], map: "Upload.created_at_index")
|
@@index([createdAt], map: "Upload.created_at_index")
|
||||||
@@index([itemId], map: "Upload.itemId_index")
|
|
||||||
@@index([userId], map: "Upload.userId_index")
|
@@index([userId], map: "Upload.userId_index")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -267,7 +281,6 @@ model Item {
|
||||||
company String?
|
company String?
|
||||||
weightedVotes Float @default(0)
|
weightedVotes Float @default(0)
|
||||||
boost Int @default(0)
|
boost Int @default(0)
|
||||||
uploadId Int?
|
|
||||||
pollCost Int?
|
pollCost Int?
|
||||||
paidImgLink Boolean @default(false)
|
paidImgLink Boolean @default(false)
|
||||||
commentMsats BigInt @default(0)
|
commentMsats BigInt @default(0)
|
||||||
|
@ -299,10 +312,12 @@ model Item {
|
||||||
PollOption PollOption[]
|
PollOption PollOption[]
|
||||||
PollVote PollVote[]
|
PollVote PollVote[]
|
||||||
ThreadSubscription ThreadSubscription[]
|
ThreadSubscription ThreadSubscription[]
|
||||||
upload Upload?
|
|
||||||
User User[]
|
User User[]
|
||||||
itemForwards ItemForward[]
|
itemForwards ItemForward[]
|
||||||
|
ItemUpload ItemUpload[]
|
||||||
|
uploadId Int?
|
||||||
|
|
||||||
|
@@index([uploadId])
|
||||||
@@index([bio], map: "Item.bio_index")
|
@@index([bio], map: "Item.bio_index")
|
||||||
@@index([createdAt], map: "Item.created_at_index")
|
@@index([createdAt], map: "Item.created_at_index")
|
||||||
@@index([freebie], map: "Item.freebie_index")
|
@@index([freebie], map: "Item.freebie_index")
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { deleteObjects } from '../api/s3'
|
||||||
|
|
||||||
|
export function deleteUnusedImages ({ models }) {
|
||||||
|
return async function ({ name }) {
|
||||||
|
console.log('running', name)
|
||||||
|
|
||||||
|
// delete all images in database and S3 which weren't paid in the last 24 hours
|
||||||
|
const unpaidImages = await models.$queryRaw`
|
||||||
|
SELECT id
|
||||||
|
FROM "Upload"
|
||||||
|
WHERE (paid = 'f'
|
||||||
|
OR (
|
||||||
|
-- for non-textarea images, they are free and paid is null
|
||||||
|
paid IS NULL
|
||||||
|
-- if the image is not used by a user or item (eg jobs), delete it
|
||||||
|
AND NOT EXISTS (SELECT * FROM users WHERE "photoId" = "Upload".id)
|
||||||
|
AND NOT EXISTS (SELECT * FROM "Item" WHERE "uploadId" = "Upload".id)
|
||||||
|
))
|
||||||
|
AND created_at < date_trunc('hour', now() - interval '24 hours')`
|
||||||
|
|
||||||
|
const s3Keys = unpaidImages.map(({ id }) => id)
|
||||||
|
console.log('deleting images:', s3Keys)
|
||||||
|
await deleteObjects(s3Keys)
|
||||||
|
await models.upload.deleteMany({ where: { id: { in: s3Keys } } })
|
||||||
|
}
|
||||||
|
}
|
|
@ -64,10 +64,10 @@ export function imgproxy ({ models }) {
|
||||||
let imgproxyUrls = {}
|
let imgproxyUrls = {}
|
||||||
try {
|
try {
|
||||||
if (item.text) {
|
if (item.text) {
|
||||||
imgproxyUrls = await createImgproxyUrls(id, item.text, { forceFetch })
|
imgproxyUrls = await createImgproxyUrls(id, item.text, { models, forceFetch })
|
||||||
}
|
}
|
||||||
if (item.url && !isJob(item)) {
|
if (item.url && !isJob(item)) {
|
||||||
imgproxyUrls = { ...imgproxyUrls, ...(await createImgproxyUrls(id, item.url, { forceFetch })) }
|
imgproxyUrls = { ...imgproxyUrls, ...(await createImgproxyUrls(id, item.url, { models, forceFetch })) }
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('[imgproxy] error:', err)
|
console.log('[imgproxy] error:', err)
|
||||||
|
@ -81,7 +81,7 @@ export function imgproxy ({ models }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createImgproxyUrls = async (id, text, { forceFetch }) => {
|
export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {
|
||||||
const urls = extractUrls(text)
|
const urls = extractUrls(text)
|
||||||
console.log('[imgproxy] id:', id, '-- extracted urls:', urls)
|
console.log('[imgproxy] id:', id, '-- extracted urls:', urls)
|
||||||
// resolutions that we target:
|
// resolutions that we target:
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { authenticatedLndGrpc } from 'ln-service'
|
||||||
import { views, rankViews } from './views.js'
|
import { views, rankViews } from './views.js'
|
||||||
import { imgproxy } from './imgproxy.js'
|
import { imgproxy } from './imgproxy.js'
|
||||||
import { deleteItem } from './ephemeralItems.js'
|
import { deleteItem } from './ephemeralItems.js'
|
||||||
|
import { deleteUnusedImages } from './deleteUnusedImages.js'
|
||||||
|
|
||||||
const { loadEnvConfig } = nextEnv
|
const { loadEnvConfig } = nextEnv
|
||||||
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
|
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
|
||||||
|
@ -70,6 +71,7 @@ async function work () {
|
||||||
await boss.work('rankViews', rankViews(args))
|
await boss.work('rankViews', rankViews(args))
|
||||||
await boss.work('imgproxy', imgproxy(args))
|
await boss.work('imgproxy', imgproxy(args))
|
||||||
await boss.work('deleteItem', deleteItem(args))
|
await boss.work('deleteItem', deleteItem(args))
|
||||||
|
await boss.work('deleteUnusedImages', deleteUnusedImages(args))
|
||||||
|
|
||||||
console.log('working jobs')
|
console.log('working jobs')
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue