import { pick } from 'lodash'; import { store } from 'react-easy-state'; import axios, { AxiosError } from 'axios'; import { User, Proposal, CCR, Contribution, ContributionArgs, RFP, RFPArgs, EmailExample, PageQuery, PageData, CommentArgs, } from './types'; // API const api = axios.create({ baseURL: process.env.BACKEND_URL + '/api/v1', withCredentials: true, }); async function login(username: string, password: string) { const { data } = await api.post('/admin/login', { username, password, }); return data; } export async function refresh(password: string) { const { data } = await api.post('/admin/refresh', { password, }); return data; } async function logout() { const { data } = await api.get('/admin/logout'); return data; } async function checkLogin() { const { data } = await api.get('/admin/checklogin'); return data; } export async function get2fa() { const { data } = await api.get('/admin/2fa'); return data; } export async function get2faInit() { const { data } = await api.get('/admin/2fa/init'); return data; } export async function post2faEnable(args: { backupCodes: string[]; totpSecret: string; verifyCode: string; }) { const { data } = await api.post('/admin/2fa/enable', args); return data; } export async function post2faVerify(args: { verifyCode: string }) { const { data } = await api.post('/admin/2fa/verify', args); return data; } async function fetchStats() { const { data } = await api.get('/admin/stats'); return data; } async function fetchFinancials() { const { data } = await api.get('/admin/financials'); return data; } async function fetchUsers(params: Partial) { const { data } = await api.get('/admin/users', { params }); return data; } async function fetchUserDetail(id: number) { const { data } = await api.get(`/admin/users/${id}`); return data; } async function editUser(id: number, args: Partial) { const { data } = await api.put(`/admin/users/${id}`, args); return data; } async function deleteUser(id: number) { const { data } = await api.delete('/admin/users/' + id); return data; } async function fetchArbiters(search: string) { const { data } = await api.get(`/admin/arbiters`, { params: { search } }); return data; } async function setArbiter(proposalId: number, userId: number) { const { data } = await api.put(`/admin/arbiters`, { proposalId, userId }); return data; } async function fetchProposals(params: Partial) { const { data } = await api.get('/admin/proposals', { params }); return data; } async function fetchProposalDetail(id: number) { const { data } = await api.get(`/admin/proposals/${id}`); return data; } async function updateProposal(p: Partial) { const { data } = await api.put('/admin/proposals/' + p.proposalId, p); return data; } async function deleteProposal(id: number) { const { data } = await api.delete('/admin/proposals/' + id); return data; } async function approveDiscussion( id: number, isOpenForDiscussion: boolean, rejectReason?: string, ) { const { data } = await api.put(`/admin/proposals/${id}/discussion`, { isOpenForDiscussion, rejectReason, }); return data; } async function acceptProposal( id: number, isAccepted: boolean, withFunding: boolean, changesRequestedReason?: string, ) { const { data } = await api.put(`/admin/proposals/${id}/accept`, { isAccepted, withFunding, changesRequestedReason, }); return data; } async function rejectPermanentlyProposal(id: number, rejectReason: string) { const { data } = await api.put(`/admin/proposals/${id}/reject_permanently`, { rejectReason, }); return data; } async function markProposalChangesAsResolved(id: number) { const { data } = await api.put(`/admin/proposals/${id}/resolve`); return data; } async function cancelProposal(id: number) { const { data } = await api.put(`/admin/proposals/${id}/cancel`); return data; } async function changeProposalToAcceptedWithFunding(id: number) { const { data } = await api.put(`/admin/proposals/${id}/accept/fund`); return data; } async function fetchComments(params: Partial) { const { data } = await api.get('/admin/comments', { params }); return data; } async function updateComment(id: number, args: Partial) { const { data } = await api.put(`/admin/comments/${id}`, args); return data; } async function markMilestonePaid(proposalId: number, milestoneId: number, txId: string) { const { data } = await api.put( `/admin/proposals/${proposalId}/milestone/${milestoneId}/paid`, { txId }, ); return data; } async function getEmailExample(type: string) { const { data } = await api.get(`/admin/email/example/${type}`); return data; } async function fetchCCRDetail(id: number) { const { data } = await api.get(`/admin/ccrs/${id}`); return data; } async function approveCCR(id: number, isAccepted: boolean, rejectReason?: string) { const { data } = await api.put(`/admin/ccrs/${id}/accept`, { isAccepted, rejectReason, }); return data; } async function rejectPermanentlyCcr(id: number, rejectReason: string) { const { data } = await api.put(`/admin/ccrs/${id}/reject_permanently`, { rejectReason, }); return data; } async function fetchCCRs(params: Partial) { const { data } = await api.get(`/admin/ccrs`, { params }); return data; } export async function deleteCCR(id: number) { await api.delete(`/admin/ccrs/${id}`); } async function getRFPs() { const { data } = await api.get(`/admin/rfps`); return data; } async function createRFP(args: RFPArgs) { const { data } = await api.post('/admin/rfps', args); return data; } async function editRFP(id: number, args: RFPArgs) { const { data } = await api.put(`/admin/rfps/${id}`, args); return data; } async function deleteRFP(id: number) { await api.delete(`/admin/rfps/${id}`); } async function getContributions(params: PageQuery) { const { data } = await api.get('/admin/contributions', { params }); return data; } async function getContribution(id: number) { const { data } = await api.get(`/admin/contributions/${id}`); return data; } async function createContribution(args: ContributionArgs) { const { data } = await api.post('/admin/contributions', args); return data; } async function editContribution(id: number, args: ContributionArgs) { const { data } = await api.put(`/admin/contributions/${id}`, args); return data; } interface QuarterData { q1: string; q2: string; q3: string; q4: string; yearTotal: string; } // STORE const app = store({ /*** DATA ***/ hasCheckedLogin: false, isLoggedIn: false, is2faAuthed: false, loginError: '', generalError: [] as string[], statsFetched: false, statsFetching: false, stats: { userCount: 0, proposalCount: 0, ccrPendingCount: 0, proposalPendingCount: 0, proposalNoArbiterCount: 0, proposalMilestonePayoutsCount: 0, contributionRefundableCount: 0, }, financialsFetched: false, financialsFetching: false, financials: { grants: { total: '0', matching: '0', bounty: '0', }, payouts: { total: '0', due: '0', paid: '0', future: '0', }, payoutsByQuarter: {} as { [type: string]: QuarterData }, }, users: { page: createDefaultPageData('EMAIL:DESC'), }, userSaving: false, userSaved: false, userDetailFetching: false, userDetail: null as null | User, userDeleting: false, userDeleted: false, arbiterSaving: false, arbiterSaved: false, arbitersSearch: { search: '', results: [] as User[], fetching: false, error: null as string | null, }, proposals: { page: createDefaultPageData('CREATED:DESC'), }, proposalDetail: null as null | Proposal, proposalDetailFetching: false, proposalDetailApprovingDiscussion: false, proposalDetailMarkingChangesAsResolved: false, proposalDetailAcceptingProposal: false, proposalDetailMarkingMilestonePaid: false, proposalDetailCanceling: false, proposalDetailUpdating: false, proposalDetailUpdated: false, proposalDetailChangingToAcceptedWithFunding: false, proposalDetailRejectingPermanently: false, ccrs: { page: createDefaultPageData('CREATED:DESC'), }, ccrSaving: false, ccrSaved: false, ccrDeleting: false, ccrDeleted: false, ccrDetail: null as null | CCR, ccrDetailFetching: false, ccrDetailApproving: false, ccrDetailMarkingMilestonePaid: false, ccrDetailCanceling: false, ccrDetailUpdating: false, ccrDetailUpdated: false, ccrDetailChangingToAcceptedWithFunding: false, ccrDetailRejectingPermanently: false, ccrCreatedRFPId: null, comments: { page: createDefaultPageData('CREATED:DESC'), }, commentSaving: false, commentSaved: false, rfps: [] as RFP[], rfpsFetching: false, rfpsFetched: false, rfpSaving: false, rfpSaved: false, rfpDeleting: false, rfpDeleted: false, contributions: { page: createDefaultPageData('CREATED:DESC'), }, contributionDetail: null as null | Contribution, contributionDetailFetching: false, contributionSaving: false, contributionSaved: false, emailExamples: {} as { [type: string]: EmailExample }, /*** ACTIONS ***/ removeGeneralError(i: number) { app.generalError.splice(i, 1); }, updateProposalInStore(p: Proposal) { const index = app.proposals.page.items.findIndex(x => x.proposalId === p.proposalId); if (index > -1) { app.proposals.page.items[index] = p; } if (app.proposalDetail && app.proposalDetail.proposalId === p.proposalId) { app.proposalDetail = p; } }, updateUserInStore(u: User) { const index = app.users.page.items.findIndex(x => x.userid === u.userid); if (index > -1) { app.users.page.items[index] = u; } if (app.userDetail && app.userDetail.userid === u.userid) { app.userDetail = { ...app.userDetail, ...u, }; } }, // Auth async checkLogin() { const res = await checkLogin(); app.isLoggedIn = res.isLoggedIn; app.is2faAuthed = res.is2faAuthed; app.hasCheckedLogin = true; }, async login(username: string, password: string) { try { const res = await login(username, password); app.isLoggedIn = res.isLoggedIn; app.is2faAuthed = res.is2faAuthed; } catch (e) { app.loginError = e.response.data.message; } }, async logout() { try { const res = await logout(); app.isLoggedIn = res.isLoggedIn; app.is2faAuthed = res.is2faAuthed; } catch (e) { app.generalError.push(e.toString()); } }, async fetchStats() { app.statsFetching = true; try { app.stats = await fetchStats(); app.statsFetched = true; } catch (e) { handleApiError(e); } app.statsFetching = false; }, async fetchFinancials() { app.financialsFetching = true; try { app.financials = await fetchFinancials(); app.financialsFetched = true; } catch (e) { handleApiError(e); } app.financialsFetching = false; }, // Users async fetchUsers() { return await pageFetch(app.users, fetchUsers); }, setUserPageQuery(params: Partial) { setPageParams(app.users, params); }, resetUserPageQuery() { resetPageParams(app.users); }, async fetchUserDetail(id: number) { app.userDetailFetching = true; try { app.userDetail = await fetchUserDetail(id); } catch (e) { handleApiError(e); } app.userDetailFetching = false; }, async editUser(id: number, args: Partial) { app.userSaving = true; app.userSaved = false; try { const user = await editUser(id, args); app.updateUserInStore(user); app.userSaved = true; } catch (e) { handleApiError(e); } app.userSaving = false; }, async deleteUser(id: number) { app.userDeleting = false; app.userDeleted = false; try { await deleteUser(id); app.users.page.items = app.users.page.items.filter(u => u.userid !== id); app.userDeleted = true; app.userDetail = null; } catch (e) { handleApiError(e); } app.userDeleting = false; }, // Arbiters async searchArbiters(search: string) { app.arbitersSearch = { ...app.arbitersSearch, search, fetching: true, }; try { const data = await fetchArbiters(search); app.arbitersSearch = { ...app.arbitersSearch, ...data, }; } catch (e) { handleApiError(e); } app.arbitersSearch.fetching = false; }, async searchArbitersClear() { app.arbitersSearch = { search: '', results: [] as User[], fetching: false, error: null, }; }, async setArbiter(proposalId: number, userId: number) { app.arbiterSaving = true; app.arbiterSaved = false; try { const { proposal, user } = await setArbiter(proposalId, userId); this.updateProposalInStore(proposal); this.updateUserInStore(user); app.arbiterSaved = true; } catch (e) { handleApiError(e); } app.arbiterSaving = false; }, // CCRS async fetchCCRs() { return await pageFetch(app.ccrs, fetchCCRs); }, setCCRPageQuery(params: Partial) { setPageParams(app.ccrs, params); }, resetCCRPageQuery() { resetPageParams(app.ccrs); }, async fetchCCRDetail(id: number) { app.ccrDetailFetching = true; try { app.ccrDetail = await fetchCCRDetail(id); } catch (e) { handleApiError(e); } app.ccrDetailFetching = false; }, async approveCCR(isAccepted: boolean, rejectReason?: string) { if (!app.ccrDetail) { const m = 'store.approveCCR(): Expected ccrDetail to be populated!'; app.generalError.push(m); console.error(m); return; } app.ccrCreatedRFPId = null; app.ccrDetailApproving = true; try { const { ccrId } = app.ccrDetail; const res = await approveCCR(ccrId, isAccepted, rejectReason); await app.fetchCCRs(); await app.fetchRFPs(); if (isAccepted) { app.ccrCreatedRFPId = res.rfpId; } } catch (e) { handleApiError(e); } app.ccrDetailApproving = false; }, async rejectPermanentlyCcr(rejectReason: string) { if (!app.ccrDetail) { const m = 'store.rejectPermanentlyCcr(): Expected ccrDetail to be populated!'; app.generalError.push(m); console.error(m); return; } app.ccrDetailRejectingPermanently = true; try { const { ccrId } = app.ccrDetail; await rejectPermanentlyCcr(ccrId, rejectReason); await app.fetchCCRDetail(ccrId); } catch (e) { handleApiError(e); } app.ccrDetailRejectingPermanently = false; }, // Proposals async fetchProposals() { return await pageFetch(app.proposals, fetchProposals); }, setProposalPageQuery(params: Partial) { setPageParams(app.proposals, params); }, resetProposalPageQuery() { resetPageParams(app.proposals); }, async fetchProposalDetail(id: number) { app.proposalDetailFetching = true; try { app.proposalDetail = await fetchProposalDetail(id); } catch (e) { handleApiError(e); } app.proposalDetailFetching = false; }, async updateProposalDetail(updates: Partial) { if (!app.proposalDetail) { return; } app.proposalDetailUpdating = true; app.proposalDetailUpdated = false; try { const res = await updateProposal({ ...updates, proposalId: app.proposalDetail.proposalId, }); app.updateProposalInStore(res); app.proposalDetailUpdated = true; } catch (e) { handleApiError(e); } app.proposalDetailUpdating = false; }, async deleteProposal(id: number) { try { await deleteProposal(id); app.proposals.page.items = app.proposals.page.items.filter( p => p.proposalId === id, ); } catch (e) { handleApiError(e); } }, async acceptProposal( isAccepted: boolean, withFunding: boolean, changesRequestedReason?: string, ) { if (!app.proposalDetail) { const m = 'store.acceptProposal(): Expected proposalDetail to be populated!'; app.generalError.push(m); console.error(m); return; } app.proposalDetailAcceptingProposal = true; try { const { proposalId } = app.proposalDetail; const res = await acceptProposal( proposalId, isAccepted, withFunding, changesRequestedReason, ); app.updateProposalInStore(res); } catch (e) { handleApiError(e); } app.proposalDetailAcceptingProposal = false; }, async approveDiscussion(isOpenForDiscussion: boolean, rejectReason?: string) { if (!app.proposalDetail) { const m = 'store.approveDiscussion(): Expected proposalDetail to be populated!'; app.generalError.push(m); console.error(m); return; } app.proposalDetailApprovingDiscussion = true; try { const { proposalId } = app.proposalDetail; const res = await approveDiscussion(proposalId, isOpenForDiscussion, rejectReason); app.updateProposalInStore(res); } catch (e) { handleApiError(e); } app.proposalDetailApprovingDiscussion = false; }, async rejectPermanentlyProposal(rejectReason: string) { if (!app.proposalDetail) { const m = 'store.rejectPermanentlyProposal(): Expected proposalDetail to be populated!'; app.generalError.push(m); console.error(m); return; } app.proposalDetailRejectingPermanently = true; try { const { proposalId } = app.proposalDetail; const res = await rejectPermanentlyProposal(proposalId, rejectReason); app.updateProposalInStore(res); } catch (e) { handleApiError(e); } app.proposalDetailRejectingPermanently = false; }, async markProposalChangesAsResolved() { if (!app.proposalDetail) { const m = 'store.approveDiscussion(): Expected proposalDetail to be populated!'; app.generalError.push(m); return; } let success = false; app.proposalDetailMarkingChangesAsResolved = true; try { const { proposalId } = app.proposalDetail; const res = await markProposalChangesAsResolved(proposalId); app.updateProposalInStore(res); success = true; } catch (e) { handleApiError(e); success = false; } app.proposalDetailMarkingChangesAsResolved = false; return success; }, async cancelProposal(id: number) { app.proposalDetailCanceling = true; try { const res = await cancelProposal(id); app.updateProposalInStore(res); } catch (e) { handleApiError(e); } app.proposalDetailCanceling = false; }, async changeProposalToAcceptedWithFunding(id: number) { app.proposalDetailChangingToAcceptedWithFunding = true; try { const res = await changeProposalToAcceptedWithFunding(id); app.updateProposalInStore(res); } catch (e) { handleApiError(e); } app.proposalDetailChangingToAcceptedWithFunding = false; }, async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) { app.proposalDetailMarkingMilestonePaid = true; try { const res = await markMilestonePaid(proposalId, milestoneId, txId); app.updateProposalInStore(res); } catch (e) { handleApiError(e); } app.proposalDetailMarkingMilestonePaid = false; }, // Comments async fetchComments() { return await pageFetch(app.comments, fetchComments); }, setCommentPageParams(params: Partial) { setPageParams(app.comments, params); }, resetCommentPageParams() { resetPageParams(app.comments); }, async updateComment(id: number, args: Partial) { app.commentSaving = true; app.commentSaved = false; try { await updateComment(id, args); app.commentSaved = true; await app.fetchComments(); } catch (e) { handleApiError(e); } app.commentSaving = false; }, // Email async getEmailExample(type: string) { try { const example = await getEmailExample(type); app.emailExamples = { ...app.emailExamples, [type]: example, }; } catch (e) { handleApiError(e); } }, // RFPs async fetchRFPs() { app.rfpsFetching = true; try { app.rfps = await getRFPs(); app.rfpsFetched = true; } catch (e) { handleApiError(e); } app.rfpsFetching = false; }, async createRFP(args: RFPArgs) { app.rfpSaving = true; try { const data = await createRFP(args); app.rfps = [data, ...app.rfps]; app.rfpSaved = true; } catch (e) { handleApiError(e); } app.rfpSaving = false; }, async editRFP(id: number, args: RFPArgs) { app.rfpSaving = true; app.rfpSaved = false; try { await editRFP(id, args); app.rfpSaved = true; await app.fetchRFPs(); } catch (e) { handleApiError(e); } app.rfpSaving = false; }, async deleteRFP(id: number) { app.rfpDeleting = true; app.rfpDeleted = false; try { await deleteRFP(id); app.rfps = app.rfps.filter(rfp => rfp.id !== id); app.rfpDeleted = true; } catch (e) { handleApiError(e); } app.rfpDeleting = false; }, // Contributions async fetchContributions() { return await pageFetch(app.contributions, getContributions); }, setContributionPageQuery(params: Partial) { setPageParams(app.contributions, params); }, resetContributionPageQuery() { resetPageParams(app.contributions); }, async fetchContributionDetail(id: number) { app.contributionDetailFetching = true; try { app.contributionDetail = await getContribution(id); } catch (e) { handleApiError(e); } app.contributionDetailFetching = false; }, async editContribution(id: number, args: ContributionArgs) { app.contributionSaving = true; app.contributionSaved = false; try { await editContribution(id, args); app.contributionSaved = true; } catch (e) { handleApiError(e); } app.contributionSaving = false; }, async createContribution(args: ContributionArgs) { app.contributionSaving = true; app.contributionSaved = false; try { await createContribution(args); app.contributionSaved = true; } catch (e) { handleApiError(e); } app.contributionSaving = false; }, }); // Utils export function handleApiError(e: AxiosError) { if (e.response && e.response.data!.message) { app.generalError.push(e.response!.data.message); } else if (e.response && e.response.data!.data!) { app.generalError.push(e.response!.data.data); } else { app.generalError.push(e.toString()); } } function createDefaultPageData(sort: string): PageData { return { sort, page: 1, search: '', filters: [] as string[], pageSize: 0, total: 0, items: [] as T[], fetching: false, fetched: false, }; } type FNFetchPage = (params: PageQuery) => Promise; interface PageParent { page: PageData; } async function pageFetch(ref: PageParent, fetch: FNFetchPage) { ref.page.fetching = true; try { const params = getPageParams(ref.page); const newPage = await fetch(params); ref.page = { ...ref.page, ...newPage, fetched: true, }; } catch (e) { handleApiError(e); } ref.page.fetching = false; } function getPageParams(page: PageData) { return pick(page, ['page', 'search', 'filters', 'sort']) as PageQuery; } function setPageParams(ref: PageParent, query: Partial) { // sometimes we need to reset page to 1 if (query.filters || query.search) { query.page = 1; } ref.page = { ...ref.page, ...query, }; } function resetPageParams(ref: PageParent) { ref.page.page = 1; ref.page.search = ''; ref.page.sort = 'CREATED:DESC'; ref.page.filters = []; } // Attach to window for inspection (window as any).appStore = app; // check login status periodically app.checkLogin(); window.setInterval(app.checkLogin, 10000); export type TApp = typeof app; export default app;