mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-08 20:08:05 -05:00
* Apply OpenSats UI enhancements * Add register modal and some UI fixes * Add login modal * Add reset password button * Use @t3-oss/env-nextjs for env variables * Email verification without keycloak UI * Password reset without keycloak UI * Add "My donations" page with one-time stripe donations list * Display crypto donations in "My donations" page * Donation form fixes and improvements * Display recurring annual fiat donations * Include keycloak realm export file and remove hardcoded client values * fix: correctly handle btcpay webhooks and fix donationList query * feat: add membership modal and implement membership payment using btcpay * feat: add procedure for membership purchases with stripe and use db as single source of truth for donations * feat: use webhooks to update stripe donation/membership status * feat: memberships list page and fixes * feat: db schema changes and webhook fixes * feat: re-add "donate" and add "get annual membership" buttons to project page * feat: open register modal when clicking membership button while logged out * feat: replace "Get Membership" button with "My Memberships" button when user already has a membership for that project * feat: multiple funds support * deps: bump axios * feat: add different color schemes for each fund and some fixes * feat: add home page * feat: add missing titles and responsiveness improvements * chore: add prod workflow file and compose file * chore(deploy workflow): set environment name * chore(Dockerfile): add necessary lines for prisma * chore: make it skip env validation on build * fix: prevent donation amounts from being fetched from db during build * chore(nginx.conf): remove copy-paste junk * chore(docker compose): correctly set APP_URL env * feat: replace Sendgrid with SES * deps: audit fix * chore(deploy.yml): remove unecessary env * fix: correctly manage client and server env * fix(trpc.submitApplication): get recipient emails from server side env * fix(Dockerfile): define NEXT_PUBLIC_ env on build * chore(trpc): make it log any errors * chore(trpc): improve displaying of errors * chore(trpc): improve displaying of errors * fix: buggy link buttons * feat: use single btcpay store * feat: show form 8283 info in donation form and handle tax deductible donations * feat: have only one privacy and terms page for the entire site * feat: support many social links * feat: add funding required endpoint (wip) * feat: get rates using btcpay api and small refactor * deps: audit fix * fix: correctly handle payment methods on InvoiceSettled event * fix: make index on Donation.btcPayInvoiceId * feat(funding-required): improve asset parameter response * feat(funding-required): add project_status param * feat(funding-required): add fund param * feat(funding-required): implement caching * fix(funding-required): minor fixes * feat(funding-required): add remaining_amount_<currency> fields and fixes * chore: include all services in docker-compose.dev.yml and update .env.example * feat: move terms and privacy links to footer * fix: address font not always loading bug * feat: use fund logos as header image * feat: donation confirmation email * fix: use correct stripe client for each fund on webhooks * feat: add account settings page with change password form * feat: add email change form to settings page * fix: address wrong btcpay invoice url redirect * chore: email change request debug * fix(api): better handle user attributes * feat: ui improvements * feat: add btcpay invoice item description * chore(nginx): api rate limit * feat: remove typing component from fund landing pages * feat: implement refresh token rotation using keycloak * refactor: have gross and net amounts for donations * feat: invalidate user sessions on password/email change * fix: make "Create an account" button work on donate/membership modals * refactor: project props * fix(utils.md): correctly load md project attributes * chore(prisma): make composite unique constraint for fundSlug and projectSlug on ProjectAddresses * chore: mark example project as not funded * fix(utils.md): serialization error * chore(funding-required): btcpay invoice payment methods debug * fix(funding-required): get bitcoin address from correct payment method * fix(funding-required): correctly handle project_status ANY filter * fix(btcpay webhook handler): correctly handle payment methods on InvoicePaymentSettled * chore(docker-compose.yml): expose nginx port 80 * fix(funding-required): correctly concat project url * feat: ui improvements for smaller screens * fix(btcpay webhook handler): correctly get payment method amount on InvoiceSettled * fix(btcpay webhook handler): respond with 200 immediately if there is no metadata * chore(funding-required): debugging * chore(funding-required): debugging * chore(funding-required): debugging * fix(Dockerfile): define BUILD_MODE as arg instead of env to make it blank at runtime * fix: correctly pass current and goal values to project card progress * fix(funding-required): set high monitoring time for static address invoice * fix: correctly handle refresh token expiration on the ui * feat: ui improvements * chore: update README * chore: update README * Initial site text * fix: colors * chore: mention funds accordingly in texts * chore: update realm-export.json * chore: rename docker compose files * Update emails * Form updates * Remove unused pages and page improvements * feat: allow editing navbar links for each fund * Cleanup and Firo projects * chore(deploy.yml): change deploy branch to master * fix(auth): use fetch instead of axios when fetching refresh token due to edge runtime compatibility * fix: keep empty project folders * Fix code scanning alert no. 20: DOM text reinterpreted as HTML Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * chore: sanitize md file paths * Text and link updates --------- Co-authored-by: Artur N <arturnunespe@gmail.com> Co-authored-by: Justin Ehrenhofer <12520755+SamsungGalaxyPlayer@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
267 lines
8.4 KiB
TypeScript
267 lines
8.4 KiB
TypeScript
import { NextApiRequest, NextApiResponse } from 'next'
|
|
import { FundSlug } from '@prisma/client'
|
|
import { z } from 'zod'
|
|
import dayjs from 'dayjs'
|
|
|
|
import { getProjects } from '../../utils/md'
|
|
import { env } from '../../env.mjs'
|
|
import { btcpayApi, prisma } from '../../server/services'
|
|
import { CURRENCY } from '../../config'
|
|
import {
|
|
BtcPayCreateInvoiceRes,
|
|
BtcPayGetPaymentMethodsRes,
|
|
BtcPayGetRatesRes,
|
|
DonationMetadata,
|
|
} from '../../server/types'
|
|
import { fundSlugs } from '../../utils/funds'
|
|
|
|
const ASSETS = ['BTC', 'XMR', 'USD'] as const
|
|
|
|
type Asset = (typeof ASSETS)[number]
|
|
|
|
type ResponseBody = {
|
|
title: string
|
|
fund: FundSlug
|
|
date: string
|
|
author: string
|
|
url: string
|
|
is_funded: boolean
|
|
raised_amount_percent: number
|
|
contributions: number
|
|
target_amount_btc: number
|
|
target_amount_xmr: number
|
|
target_amount_usd: number
|
|
remaining_amount_btc: number
|
|
remaining_amount_xmr: number
|
|
remaining_amount_usd: number
|
|
address_btc: string | null
|
|
address_xmr: string | null
|
|
}[]
|
|
|
|
type ResponseBodySpecificAsset = {
|
|
title: string
|
|
fund: FundSlug
|
|
date: string
|
|
author: string
|
|
url: string
|
|
is_funded: boolean
|
|
raised_amount_percent: number
|
|
contributions: number
|
|
asset: Asset
|
|
target_amount: number
|
|
remaining_amount: number
|
|
address: string | null
|
|
}[]
|
|
|
|
// The cache key should be: fund-asset-project_status
|
|
const cachedResponses: Record<
|
|
string,
|
|
{ data: ResponseBody | ResponseBodySpecificAsset; expiresAt: Date } | undefined
|
|
> = {}
|
|
|
|
const querySchema = z.object({
|
|
fund: z.enum(fundSlugs).optional(),
|
|
asset: z.enum(ASSETS).optional(),
|
|
project_status: z.enum(['FUNDED', 'NOT_FUNDED', 'ANY']).default('NOT_FUNDED'),
|
|
})
|
|
|
|
async function handle(
|
|
req: NextApiRequest,
|
|
res: NextApiResponse<ResponseBody | ResponseBodySpecificAsset>
|
|
) {
|
|
if (req.method !== 'GET') {
|
|
res.setHeader('Allow', ['GET'])
|
|
return res.status(405).end(`Method ${req.method} Not Allowed`)
|
|
}
|
|
|
|
const query = await querySchema.parseAsync(req.query)
|
|
|
|
// Get response from cache
|
|
const cacheKey = `${query.fund}-${query.asset}-${query.project_status}`
|
|
const cachedResponse = cachedResponses[cacheKey]
|
|
if (cachedResponse && cachedResponse.expiresAt > new Date()) {
|
|
return res.send(cachedResponse.data)
|
|
}
|
|
|
|
const projects = (await getProjects(query.fund)).filter((project) =>
|
|
query.project_status === 'FUNDED'
|
|
? project.isFunded
|
|
: query.project_status === 'ANY'
|
|
? true
|
|
: !project.isFunded
|
|
)
|
|
|
|
const rates: Record<string, number | undefined> = {}
|
|
|
|
// Get exchange rates if target asset is not USD (or if there is no target asset)
|
|
if (query.asset !== 'USD') {
|
|
const assetsWithoutUsd = ASSETS.filter((asset) => asset !== 'USD')
|
|
const params = assetsWithoutUsd.map((asset) => `currencyPair=${asset}_USD`).join('&')
|
|
const { data: _rates } = await btcpayApi.get<BtcPayGetRatesRes>(`/rates?${params}`)
|
|
|
|
_rates.forEach((rate) => {
|
|
const asset = rate.currencyPair.split('_')[0] as string
|
|
rates[asset] = Number(rate.rate)
|
|
})
|
|
}
|
|
|
|
let responseBody: ResponseBody | ResponseBodySpecificAsset = await Promise.all(
|
|
projects.map(async (project): Promise<ResponseBody[0]> => {
|
|
let bitcoinAddress: string | null = null
|
|
let moneroAddress: string | null = null
|
|
|
|
if (!project.isFunded) {
|
|
const existingAddresses = await prisma.projectAddresses.findUnique({
|
|
where: { projectSlug_fundSlug: { projectSlug: project.slug, fundSlug: project.fund } },
|
|
})
|
|
|
|
// Create invoice if there's no existing address
|
|
if (!existingAddresses) {
|
|
const metadata: DonationMetadata = {
|
|
userId: null,
|
|
donorName: null,
|
|
donorEmail: null,
|
|
projectSlug: project.slug,
|
|
projectName: project.title,
|
|
fundSlug: project.fund as FundSlug,
|
|
isMembership: 'false',
|
|
isSubscription: 'false',
|
|
isTaxDeductible: 'false',
|
|
staticGeneratedForApi: 'true',
|
|
}
|
|
|
|
const { data: invoice } = await btcpayApi.post<BtcPayCreateInvoiceRes>('/invoices', {
|
|
checkout: { monitoringMinutes: 9999999 },
|
|
currency: CURRENCY,
|
|
metadata,
|
|
})
|
|
|
|
const { data: paymentMethods } = await btcpayApi.get<BtcPayGetPaymentMethodsRes>(
|
|
`/invoices/${invoice.id}/payment-methods`
|
|
)
|
|
|
|
paymentMethods.forEach((paymentMethod) => {
|
|
if (paymentMethod.paymentMethod === 'BTC') {
|
|
bitcoinAddress = paymentMethod.destination
|
|
}
|
|
|
|
if (paymentMethod.paymentMethod === 'XMR') {
|
|
moneroAddress = paymentMethod.destination
|
|
}
|
|
})
|
|
|
|
if (!bitcoinAddress && process.env.NODE_ENV !== 'development')
|
|
throw new Error(
|
|
'[/api/funding-required] Could not get bitcoin address from payment methods.'
|
|
)
|
|
|
|
if (!moneroAddress)
|
|
throw new Error(
|
|
'[/api/funding-required] Could not get monero address from payment methods.'
|
|
)
|
|
|
|
await prisma.projectAddresses.create({
|
|
data: {
|
|
projectSlug: project.slug,
|
|
fundSlug: project.fund,
|
|
btcPayInvoiceId: invoice.id,
|
|
bitcoinAddress: bitcoinAddress || '',
|
|
moneroAddress: moneroAddress,
|
|
},
|
|
})
|
|
}
|
|
|
|
if (existingAddresses) {
|
|
bitcoinAddress = existingAddresses.bitcoinAddress
|
|
moneroAddress = existingAddresses.moneroAddress
|
|
}
|
|
}
|
|
|
|
const targetAmountBtc = project.goal / (rates.BTC || 0)
|
|
const targetAmountXmr = project.goal / (rates.XMR || 0)
|
|
const targetAmountUsd = project.goal
|
|
|
|
const allDonationsSumUsd =
|
|
project.totalDonationsBTCInFiat +
|
|
project.totalDonationsXMRInFiat +
|
|
project.totalDonationsFiat
|
|
|
|
const remainingAmountBtc = (project.goal - allDonationsSumUsd) / (rates.BTC || 0)
|
|
const remainingAmountXmr = (project.goal - allDonationsSumUsd) / (rates.XMR || 0)
|
|
const remainingAmountUsd = project.goal - allDonationsSumUsd
|
|
|
|
return {
|
|
title: project.title,
|
|
fund: project.fund,
|
|
date: project.date,
|
|
author: project.nym,
|
|
url: `${env.APP_URL}/${project.fund}/${project.slug}`,
|
|
is_funded: !!project.isFunded,
|
|
target_amount_btc: Number(targetAmountBtc.toFixed(8)),
|
|
target_amount_xmr: Number(targetAmountXmr.toFixed(12)),
|
|
target_amount_usd: Number(targetAmountUsd.toFixed(2)),
|
|
remaining_amount_btc: Number((remainingAmountBtc > 0 ? remainingAmountBtc : 0).toFixed(8)),
|
|
remaining_amount_xmr: Number((remainingAmountXmr > 0 ? remainingAmountXmr : 0).toFixed(12)),
|
|
remaining_amount_usd: Number((remainingAmountUsd > 0 ? remainingAmountUsd : 0).toFixed(2)),
|
|
address_btc: bitcoinAddress,
|
|
address_xmr: moneroAddress,
|
|
raised_amount_percent: Math.floor(
|
|
((project.totalDonationsBTCInFiat +
|
|
project.totalDonationsXMRInFiat +
|
|
project.totalDonationsFiat) /
|
|
project.goal) *
|
|
100
|
|
),
|
|
contributions: project.numDonationsBTC + project.numDonationsXMR + project.numDonationsFiat,
|
|
}
|
|
})
|
|
)
|
|
|
|
if (query.asset) {
|
|
responseBody = responseBody.map<ResponseBodySpecificAsset[0]>((project) => {
|
|
const targetAmounts: Record<Asset, number> = {
|
|
BTC: project.target_amount_btc,
|
|
XMR: project.target_amount_xmr,
|
|
USD: project.target_amount_usd,
|
|
}
|
|
|
|
const remainingAmounts: Record<Asset, number> = {
|
|
BTC: project.remaining_amount_btc,
|
|
XMR: project.remaining_amount_xmr,
|
|
USD: project.remaining_amount_usd,
|
|
}
|
|
|
|
const addresses: Record<Asset, string | null> = {
|
|
BTC: project.address_btc,
|
|
XMR: project.address_xmr,
|
|
USD: null,
|
|
}
|
|
|
|
return {
|
|
title: project.title,
|
|
fund: project.fund,
|
|
date: project.date,
|
|
author: project.author,
|
|
url: project.url,
|
|
is_funded: project.is_funded,
|
|
target_amount: targetAmounts[query.asset!],
|
|
remaining_amount: remainingAmounts[query.asset!],
|
|
address: addresses[query.asset!],
|
|
raised_amount_percent: project.raised_amount_percent,
|
|
contributions: project.contributions,
|
|
asset: query.asset!,
|
|
}
|
|
})
|
|
}
|
|
|
|
// Store response in cache
|
|
cachedResponses[cacheKey] = {
|
|
data: responseBody,
|
|
expiresAt: dayjs().add(10, 'minutes').toDate(),
|
|
}
|
|
|
|
return res.send(responseBody)
|
|
}
|
|
|
|
export default handle
|