diff --git a/back-end/routes/index.ts b/back-end/routes/index.ts index 39eccc2..d7b2fea 100644 --- a/back-end/routes/index.ts +++ b/back-end/routes/index.ts @@ -5,6 +5,7 @@ import v1_account from './v1/account.ts' import v1_event from './v1/event.ts' import v1_purchase from './v1/purchase.ts' import v1_misc from './v1/misc.ts' +import v1_stats from './v1/stats.ts' export const router = new Router() @@ -15,6 +16,7 @@ v1_account(router) v1_event(router) v1_misc(router) v1_purchase(router) +v1_stats(router) router.get('/', (ctx) => { ctx.response.body = 'OK' diff --git a/back-end/routes/v1/stats.ts b/back-end/routes/v1/stats.ts new file mode 100644 index 0000000..dc45f96 --- /dev/null +++ b/back-end/routes/v1/stats.ts @@ -0,0 +1,25 @@ +import { Router, Status } from 'oak' +import { defineRoute } from './_common.ts' +import {withDBConnection, festivalStats, accountReferralStatus} from '../../utils/db.ts' + +export default function register(router: Router) { + defineRoute(router, { + endpoint: '/stats', + method: 'get', + requireAuth: true, + handler: async ({ jwt: { account_id }}) => { + // only these accounts are allowed to see stats + // if ([ + // 'allowed-account-id-1', + // ].indexOf(account_id) === -1) { + // return [null, Status.Forbidden] + // } + + const stats = await withDBConnection(db => { + return festivalStats(db) + }) + + return [stats, Status.OK] + } + }) +} diff --git a/back-end/types/misc.ts b/back-end/types/misc.ts index 697f698..fa36021 100644 --- a/back-end/types/misc.ts +++ b/back-end/types/misc.ts @@ -34,4 +34,6 @@ export const PURCHASE_TYPES_BY_TYPE = objectFromEntries( TABLE_ROWS.purchase_type.map(r => [r.purchase_type_id, r]) ) -export type PurchaseType = keyof typeof PURCHASE_TYPES_BY_TYPE \ No newline at end of file +export type PurchaseType = keyof typeof PURCHASE_TYPES_BY_TYPE + +export type PurchaseCountMap = {[key in PurchaseType]: number} diff --git a/back-end/types/route-types.ts b/back-end/types/route-types.ts index 66b9454..c1f07c5 100644 --- a/back-end/types/route-types.ts +++ b/back-end/types/route-types.ts @@ -1,5 +1,5 @@ import { TABLE_ROWS, Tables } from "./db-types.ts" -import { AttendeeInfo, FullAccountInfo } from "./misc.ts" +import {AttendeeInfo, FullAccountInfo, PurchaseCountMap } from "./misc.ts" export type Routes = { '/account': { @@ -104,7 +104,13 @@ export type Routes = { start_date: string, end_date: string } + }, + '/stats': { + method: 'get', + body: undefined, + response: { accounts: number, purchases: PurchaseCountMap } } + } export type EventJson = Omit & { diff --git a/back-end/utils/db.ts b/back-end/utils/db.ts index d3b4f4b..7e45440 100644 --- a/back-end/utils/db.ts +++ b/back-end/utils/db.ts @@ -4,9 +4,10 @@ import { REFERRAL_MAXES, } from './constants.ts' import { TableName, Tables } from '../types/db-types.ts' -import { Maybe } from "../types/misc.ts" -import { _format } from 'https://deno.land/std@0.160.0/path/_util.ts' +import {Maybe, PurchaseCountMap } from "../types/misc.ts" import { WhereClause, queryTableQuery, insertTableQuery, updateTableQuery, deleteTableQuery } from './db-inner.ts' +import {Purchases} from "../types/route-types.ts"; +import {objectEntries} from "./misc.ts"; const url = new URL(env.DB_URL) @@ -217,3 +218,29 @@ export async function accountReferralStatus( allowedToPurchase: true, } } + +export async function festivalStats( + db: Pick, +): Promise<{ accounts: number, purchases: PurchaseCountMap> { + const accountRes = (await db.queryObject< + number + >` + SELECT count(*) as accounts from account where password_hash is not null + `).rows + + const purchaseRes: Purchases = (await db.queryObject< + Record + >` + SELECT purchase_type_id, count(*) as purchase_count FROM purchase GROUP BY purchase_type_id; + `).rows + + const purchases : PurchaseCountMap = {} + for (const [key, value] of objectEntries(purchaseRes)) { + purchases[value['purchase_type_id']] = Number(value['purchase_count']) + } + + return { + accounts: accountRes.length ? Number(accountRes[0].accounts) : 0, + purchases: purchases + } +} diff --git a/front-end/src/components/Stats.tsx b/front-end/src/components/Stats.tsx new file mode 100644 index 0000000..339eaa2 --- /dev/null +++ b/front-end/src/components/Stats.tsx @@ -0,0 +1,65 @@ +/* eslint-disable indent */ +import React, { } from 'react' +import { observer } from 'mobx-react-lite' +import { useRequest } from '../mobx/hooks' +import Col from './core/Col' +import Store from "../Store.ts"; +import {vibefetch} from "../vibefetch.ts"; +import LoadingDots from "./core/LoadingDots.tsx"; +import { + PURCHASE_TYPES_BY_TYPE, + PurchaseCountMap, + PurchaseType +} from "../../../back-end/types/misc.ts"; +import {objectKeys} from "../../../back-end/utils/misc.ts"; + +export default observer(() => { + const stats = useRequest(async () => { + if (!Store.loggedIn) { + return undefined + } + + const { response } = await vibefetch( + Store.jwt, + '/stats', + 'get', + undefined + ) + + return response + }) + + const purchases = stats.state.result?.purchases ?? ({} as PurchaseCountMap) + const purchaseTable = Object.keys(purchases).length > 0 ? ( + + {objectKeys(purchases).map((key) => { + return ( + + + + + ); + })} +
{PURCHASE_TYPES_BY_TYPE[key].description}{purchases[key]}
+ ) :
+ + return ( + +

+ Stats +

+ + {stats.state.kind === 'loading' ? : ( + <> +
+ Accounts Created: {stats.state.result?.accounts} +
+
+

Purchases

+ {purchaseTable} +
+ + )} + + ) +}) diff --git a/front-end/src/components/Tickets.tsx b/front-end/src/components/Tickets.tsx index ed6ef96..4c16171 100644 --- a/front-end/src/components/Tickets.tsx +++ b/front-end/src/components/Tickets.tsx @@ -256,7 +256,7 @@ export class PurchaseFormState { } } - const {needsSleepingBags, needsPillow, needsBusTickets} = this.extraPurchasesForm.fieldValues + const { needsSleepingBags, needsPillow, needsBusTickets } = this.extraPurchasesForm.fieldValues if (needsSleepingBags) { purchases.SLEEPING_BAG_VIBECLIPSE_2024 = this.allAttendeeForms.length diff --git a/front-end/src/components/core/Checkbox.tsx b/front-end/src/components/core/Checkbox.tsx index bf94d4d..b216c0b 100644 --- a/front-end/src/components/core/Checkbox.tsx +++ b/front-end/src/components/core/Checkbox.tsx @@ -7,7 +7,7 @@ type Props = Omit, 'label'> & { children: React.ReactNode } -export default observer(({ value, onChange, error, onBlur, children}: Props) => { +export default observer(({ value, onChange, error, onBlur, children }: Props) => { const handleChange = useCallback(() => onChange(!value), [value, onChange]) return ( diff --git a/front-end/src/components/core/MultiView.tsx b/front-end/src/components/core/MultiView.tsx index a49daf1..950f0e9 100644 --- a/front-end/src/components/core/MultiView.tsx +++ b/front-end/src/components/core/MultiView.tsx @@ -6,7 +6,7 @@ type Props = { currentView: TView, } -function MultiView({views, currentView}: Props) { +function MultiView({ views, currentView }: Props) { return (
= Omit, 'value'> & { options: readonly { value: T, label: string }[] } -function RadioGroup({label, value, onChange, disabled, error, onBlur, options}: Props) { +function RadioGroup({ label, value, onChange, disabled, error, onBlur, options }: Props) { const changeHandlers = useStable(() => (value: T | typeof NULL) => () => // @ts-expect-error null requires a workaround diff --git a/front-end/src/components/core/RowSelect.tsx b/front-end/src/components/core/RowSelect.tsx index 64c6bee..abcb572 100644 --- a/front-end/src/components/core/RowSelect.tsx +++ b/front-end/src/components/core/RowSelect.tsx @@ -11,7 +11,7 @@ type Props = { options: readonly TOption[], } -function RowSelect({ label, disabled, value, onChange, options}: Props) { +function RowSelect({ label, disabled, value, onChange, options }: Props) { const handleChange = useStable(() => createTransformer((option: TOption) => () => onChange(option))) return ( diff --git a/front-end/src/views.ts b/front-end/src/views.ts index e04f8e6..f588816 100644 --- a/front-end/src/views.ts +++ b/front-end/src/views.ts @@ -1,6 +1,7 @@ import { objectEntries } from '../../back-end/utils/misc' import Account from './components/Account' import Tickets from './components/Tickets' +import Stats from './components/Stats' export const VIEWS = { Tickets: { @@ -22,14 +23,18 @@ export const VIEWS = { Account: { icon: 'person', component: Account - } + }, + Stats: { + icon: 'info', + component: Stats + }, } as const export const VIEWS_ARRAY = objectEntries(VIEWS) - .map(([name, {icon, component}]) => ({ name, icon, component } as const)) + .map(([name, { icon, component }]) => ({ name, icon, component } as const)) export function isViewName(str: string): str is ViewName { return Object.keys(VIEWS).includes(str) } -export type ViewName = keyof typeof VIEWS \ No newline at end of file +export type ViewName = keyof typeof VIEWS