diff --git a/admin/src/components/Emails/emails.ts b/admin/src/components/Emails/emails.ts index 3574594f..0b119ff1 100644 --- a/admin/src/components/Emails/emails.ts +++ b/admin/src/components/Emails/emails.ts @@ -77,4 +77,24 @@ export default [ title: 'Arbiter assignment', description: 'Sent if someone is made arbiter of a proposal', }, + { + id: 'milestone_request', + title: 'Milestone request', + description: 'Sent if team member has made a milestone payout request', + }, + { + id: 'milestone_accept', + title: 'Milestone accept', + description: 'Sent if arbiter approves milestone payout', + }, + { + id: 'milestone_reject', + title: 'Milestone reject', + description: 'Sent if arbiter rejects milestone payout', + }, + { + id: 'milestone_paid', + title: 'Milestone paid', + description: 'Sent when milestone is paid', + }, ] as Email[]; diff --git a/admin/src/components/Home/index.tsx b/admin/src/components/Home/index.tsx index b6a08aa4..c86acaa4 100644 --- a/admin/src/components/Home/index.tsx +++ b/admin/src/components/Home/index.tsx @@ -16,6 +16,7 @@ class Home extends React.Component { proposalCount, proposalPendingCount, proposalNoArbiterCount, + proposalMilestonePayoutsCount, } = store.stats; const actionItems = [ @@ -36,6 +37,14 @@ class Home extends React.Component { to view them. ), + !!proposalMilestonePayoutsCount && ( +
+ There are{' '} + {proposalMilestonePayoutsCount} proposals with approved payouts.{' '} + Click here to view + them. +
+ ), ].filter(Boolean); return ( diff --git a/admin/src/components/ProposalDetail/index.less b/admin/src/components/ProposalDetail/index.less index a391bd1c..211703c2 100644 --- a/admin/src/components/ProposalDetail/index.less +++ b/admin/src/components/ProposalDetail/index.less @@ -37,4 +37,13 @@ max-width: 400px; } } + + &-alert { + & pre { + margin: 1rem 0; + overflow: hidden; + word-break: break-all; + white-space: inherit; + } + } } diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index e955cef1..a0b18221 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import BN from 'bn.js'; import { view } from 'react-easy-state'; import { RouteComponentProps, withRouter } from 'react-router'; import { @@ -12,23 +13,26 @@ import { Modal, Input, Switch, + message, } from 'antd'; import TextArea from 'antd/lib/input/TextArea'; import store from 'src/store'; import { formatDateSeconds } from 'util/time'; -import { PROPOSAL_STATUS, PROPOSAL_ARBITER_STATUS } from 'src/types'; +import { PROPOSAL_STATUS, PROPOSAL_ARBITER_STATUS, MILESTONE_STAGE } from 'src/types'; import { Link } from 'react-router-dom'; import Back from 'components/Back'; import Info from 'components/Info'; import Markdown from 'components/Markdown'; import ArbiterControl from 'components/ArbiterControl'; import './index.less'; +import { toZat, fromZat } from 'src/util/units'; type Props = RouteComponentProps; const STATE = { showRejectModal: false, rejectReason: '', + paidTxId: '', }; type State = typeof STATE; @@ -68,7 +72,7 @@ class ProposalDetailNaked extends React.Component { type: 'default', className: 'ProposalDetail-controls-control', block: true, - disabled: p.status !== PROPOSAL_STATUS.LIVE + disabled: p.status !== PROPOSAL_STATUS.LIVE, }} /> ); @@ -245,6 +249,54 @@ class ProposalDetailNaked extends React.Component { /> ); + const renderMilestoneAccepted = () => { + if ( + !( + p.status === PROPOSAL_STATUS.LIVE && + p.currentMilestone && + p.currentMilestone.stage === MILESTONE_STAGE.ACCEPTED + ) + ) { + return; + } + const ms = p.currentMilestone; + const amount = fromZat( + toZat(p.target) + .mul(new BN(ms.payoutPercent)) + .divn(100), + ); + return ( + +

+ + Milestone {ms.index + 1} - {ms.title} + {' '} + was accepted on {formatDateSeconds(ms.dateAccepted)}. +

+

+ {' '} + Please make a payment of {amount.toString()} ZEC to: +

{' '} +
{p.payoutAddress}
+ this.setState({ paidTxId: e.target.value })} + onSearch={this.handlePaidMilestone} + /> + + } + /> + ); + }; + const renderDeetItem = (name: string, val: any) => (
{name} @@ -264,6 +316,7 @@ class ProposalDetailNaked extends React.Component { {renderRejected()} {renderNominateArbiter()} {renderNominatedArbiter()} + {renderMilestoneAccepted()} {p.brief} @@ -295,12 +348,12 @@ class ProposalDetailNaked extends React.Component { {renderDeetItem('id', p.proposalId)} {renderDeetItem('created', formatDateSeconds(p.dateCreated))} {renderDeetItem('status', p.status)} + {renderDeetItem('stage', p.stage)} {renderDeetItem('category', p.category)} {renderDeetItem('target', p.target)} {renderDeetItem('contributed', p.contributed)} {renderDeetItem('funded (inc. matching)', p.funded)} {renderDeetItem('matching', p.contributionMatching)} - {renderDeetItem( 'arbiter', <> @@ -365,6 +418,13 @@ class ProposalDetailNaked extends React.Component { store.updateProposalDetail({ contributionMatching }); } }; + + private handlePaidMilestone = async () => { + const pid = store.proposalDetail!.proposalId; + const mid = store.proposalDetail!.currentMilestone!.id; + await store.markMilestonePaid(pid, mid, this.state.paidTxId); + message.success('Marked milestone paid.'); + }; } const ProposalDetail = withRouter(view(ProposalDetailNaked)); diff --git a/admin/src/store.ts b/admin/src/store.ts index 13774f78..04646d43 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -95,6 +95,14 @@ async function approveProposal(id: number, isApprove: boolean, rejectReason?: st 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; @@ -154,6 +162,7 @@ const app = store({ proposalCount: 0, proposalPendingCount: 0, proposalNoArbiterCount: 0, + proposalMilestonePayoutsCount: 0, }, usersFetching: false, @@ -178,6 +187,7 @@ const app = store({ proposalDetail: null as null | Proposal, proposalDetailFetching: false, proposalDetailApproving: false, + proposalDetailMarkingMilestonePaid: false, rfps: [] as RFP[], rfpsFetching: false, @@ -424,6 +434,17 @@ const app = store({ app.proposalDetailApproving = 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; + }, + // Email async getEmailExample(type: string) { diff --git a/admin/src/types.ts b/admin/src/types.ts index 0473d561..81a63a61 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -4,10 +4,24 @@ export interface SocialMedia { service: string; username: string; } +// NOTE: sync with backend/grant/utils/enums.py MilestoneStage +export enum MILESTONE_STAGE { + IDLE = 'IDLE', + REQUESTED = 'REQUESTED', + REJECTED = 'REJECTED', + ACCEPTED = 'ACCEPTED', + PAID = 'PAID', +} export interface Milestone { + id: number; + index: number; content: string; - dateCreated: string; - dateEstimated: string; + dateCreated: number; + dateEstimated: number; + dateRequested: number; + dateAccepted: number; + dateRejected: number; + datePaid: number; immediatePayout: boolean; payoutPercent: string; stage: string; @@ -61,7 +75,7 @@ export interface Proposal { proposalId: number; brief: string; status: PROPOSAL_STATUS; - proposalAddress: string; + payoutAddress: string; dateCreated: number; dateApproved: number; datePublished: number; @@ -70,6 +84,7 @@ export interface Proposal { stage: string; category: string; milestones: Milestone[]; + currentMilestone?: Milestone; team: User[]; comments: Comment[]; contractStatus: string; diff --git a/admin/src/util/filters.ts b/admin/src/util/filters.ts index df91f8d2..3878eee4 100644 --- a/admin/src/util/filters.ts +++ b/admin/src/util/filters.ts @@ -3,6 +3,7 @@ import { RFP_STATUSES, CONTRIBUTION_STATUSES, PROPOSAL_ARBITER_STATUSES, + MILESTONE_STAGES, } from './statuses'; export interface Filter { @@ -41,6 +42,14 @@ const PROPOSAL_FILTERS = PROPOSAL_STATUSES.map(s => ({ color: s.tagColor, group: 'Arbiter', })), + ) + .concat( + MILESTONE_STAGES.map(s => ({ + id: `MILESTONE_${s.id}`, + display: `Milestone: ${s.tagDisplay}`, + color: s.tagColor, + group: 'Milestone', + })), ); export const proposalFilters: Filters = { diff --git a/admin/src/util/statuses.ts b/admin/src/util/statuses.ts index c9e461f0..6f965979 100644 --- a/admin/src/util/statuses.ts +++ b/admin/src/util/statuses.ts @@ -3,6 +3,7 @@ import { RFP_STATUS, CONTRIBUTION_STATUS, PROPOSAL_ARBITER_STATUS, + MILESTONE_STAGE, } from 'src/types'; export interface StatusSoT { @@ -12,6 +13,39 @@ export interface StatusSoT { hint: string; } +export const MILESTONE_STAGES: Array> = [ + { + id: MILESTONE_STAGE.IDLE, + tagDisplay: 'Idle', + tagColor: '#e9c510', + hint: 'Proposal has has an idle milestone.', + }, + { + id: MILESTONE_STAGE.REQUESTED, + tagDisplay: 'Requested', + tagColor: '#e9c510', + hint: 'Proposal has has a milestone with a requested payout.', + }, + { + id: MILESTONE_STAGE.REJECTED, + tagDisplay: 'Rejected', + tagColor: '#e9c510', + hint: 'Proposal has has a milestone with a rejected payout.', + }, + { + id: MILESTONE_STAGE.ACCEPTED, + tagDisplay: 'Accepted', + tagColor: '#e9c510', + hint: 'Proposal has an accepted milestone, and awaits payment.', + }, + { + id: MILESTONE_STAGE.PAID, + tagDisplay: 'Paid', + tagColor: '#e9c510', + hint: 'Proposal has a paid milestone.', + }, +]; + export const PROPOSAL_STATUSES: Array> = [ { id: PROPOSAL_STATUS.APPROVED, diff --git a/admin/src/util/units.ts b/admin/src/util/units.ts new file mode 100644 index 00000000..d96c68b8 --- /dev/null +++ b/admin/src/util/units.ts @@ -0,0 +1,63 @@ +// From https://github.com/MyCryptoHQ/MyCrypto/blob/develop/common/libs/units.ts +import BN from 'bn.js'; + +export const ZCASH_DECIMAL = 8; +export const Units = { + zat: '1', + zcash: '100000000', +}; + +export type Zat = BN; +export type UnitKey = keyof typeof Units; + +export const handleValues = (input: string | BN) => { + if (typeof input === 'string') { + return new BN(input); + } + if (typeof input === 'number') { + return new BN(input); + } + if (BN.isBN(input)) { + return input; + } else { + throw Error('unsupported value conversion'); + } +}; + +export const Zat = (input: string | BN): Zat => handleValues(input); + +const stripRightZeros = (str: string) => { + const strippedStr = str.replace(/0+$/, ''); + return strippedStr === '' ? null : strippedStr; +}; + +export const baseToConvertedUnit = (value: string, decimal: number) => { + if (decimal === 0) { + return value; + } + const paddedValue = value.padStart(decimal + 1, '0'); // 0.1 ==> + const integerPart = paddedValue.slice(0, -decimal); + const fractionPart = stripRightZeros(paddedValue.slice(-decimal)); + return fractionPart ? `${integerPart}.${fractionPart}` : `${integerPart}`; +}; + +const convertedToBaseUnit = (value: string, decimal: number) => { + if (decimal === 0) { + return value; + } + const [integerPart, fractionPart = ''] = value.split('.'); + const paddedFraction = fractionPart.padEnd(decimal, '0'); + return `${integerPart}${paddedFraction}`; +}; + +export const fromZat = (zat: Zat) => { + return baseToConvertedUnit(zat.toString(), ZCASH_DECIMAL); +}; + +export const toZat = (value: string | number): Zat => { + value = value.toString(); + const zat = convertedToBaseUnit(value, ZCASH_DECIMAL); + return Zat(zat); +}; + +export const getDecimalFromUnitKey = (key: UnitKey) => Units[key].length - 1; diff --git a/backend/grant/admin/example_emails.py b/backend/grant/admin/example_emails.py index dfb58229..65b7215d 100644 --- a/backend/grant/admin/example_emails.py +++ b/backend/grant/admin/example_emails.py @@ -6,12 +6,19 @@ class FakeUser(object): title = 'Email Example Dude' +class FakeMilestone(object): + id = 123 + index = 0 + title = 'Example Milestone' + + class FakeProposal(object): id = 123 title = 'Example proposal' brief = 'This is an example proposal' content = 'Example example example example' target = "100" + current_milestone = FakeMilestone() class FakeContribution(object): @@ -101,7 +108,27 @@ example_email_args = { }, 'proposal_arbiter': { 'proposal': proposal, - 'proposal_url': 'http://someproposal.com', - 'arbitration_url': 'http://arbitrationtab.com', + 'proposal_url': 'http://zfnd.org/proposals/999', + 'accept_url': 'http://zfnd.org/email/arbiter?code=blah&proposalId=999', + }, + 'milestone_request': { + 'proposal': proposal, + 'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones', + }, + 'milestone_reject': { + 'proposal': proposal, + 'admin_note': 'We noticed that the tests were failing for the features outlined in this milestone. Please address these issues.', + 'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones', + }, + 'milestone_accept': { + 'proposal': proposal, + 'amount': '33', + 'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones', + }, + 'milestone_paid': { + 'proposal': proposal, + 'amount': '33', + 'tx_explorer_url': 'http://someblockexplorer.com/tx/271857129857192579125', + 'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones', } } diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 23ac8cb9..66de4099 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -1,4 +1,4 @@ -from flask import Blueprint, request +from functools import reduce from flask import Blueprint, request from flask_yoloapi import endpoint, parameter from decimal import Decimal @@ -14,12 +14,14 @@ from grant.proposal.models import ( proposal_contribution_schema, user_proposal_contributions_schema, ) +from grant.milestone.models import Milestone from grant.user.models import User, admin_users_schema, admin_user_schema from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema from grant.utils.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout from grant.utils.misc import make_url -from grant.utils.enums import ProposalStatus, ContributionStatus, ProposalArbiterStatus +from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus, ProposalArbiterStatus, MilestoneStage from grant.utils import pagination +from grant.settings import EXPLORER_URL from sqlalchemy import func, or_ from .example_emails import example_email_args @@ -66,11 +68,17 @@ def stats(): .filter(Proposal.status == ProposalStatus.LIVE) \ .filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \ .scalar() + proposal_milestone_payouts_count = db.session.query(func.count(Proposal.id)) \ + .join(Proposal.milestones) \ + .filter(Proposal.status == ProposalStatus.LIVE) \ + .filter(Milestone.stage == MilestoneStage.ACCEPTED) \ + .scalar() return { "userCount": user_count, "proposalCount": proposal_count, "proposalPendingCount": proposal_pending_count, "proposalNoArbiterCount": proposal_no_arbiter_count, + "proposalMilestonePayoutsCount": proposal_milestone_payouts_count, } @@ -253,7 +261,44 @@ def approve_proposal(id, is_approve, reject_reason=None): db.session.commit() return proposal_schema.dump(proposal) - return {"message": "Not implemented."}, 400 + return {"message": "No proposal found."}, 404 + + +@blueprint.route("/proposals//milestone//paid", methods=["PUT"]) +@endpoint.api( + parameter('txId', type=str, required=True), +) +@admin_auth_required +def paid_milestone_payout_request(id, mid, tx_id): + proposal = Proposal.query.filter_by(id=id).first() + if not proposal: + return {"message": "No proposal matching id"}, 404 + if not proposal.is_funded: + return {"message": "Proposal is not fully funded"}, 400 + for ms in proposal.milestones: + if ms.id == int(mid): + ms.mark_paid(tx_id) + db.session.add(ms) + db.session.flush() + # check if this is the final ms, and update proposal.stage + num_paid = reduce(lambda a, x: a + (1 if x.stage == MilestoneStage.PAID else 0), proposal.milestones, 0) + if num_paid == len(proposal.milestones): + proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED + db.session.add(proposal) + db.session.flush() + db.session.commit() + # email TEAM that payout request was PAID + amount = Decimal(ms.payout_percent) * Decimal(proposal.target) / 100 + for member in proposal.team: + send_email(member.email_address, 'milestone_paid', { + 'proposal': proposal, + 'amount': amount, + 'tx_explorer_url': f'{EXPLORER_URL}transactions/{tx_id}', + 'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'), + }) + return proposal_schema.dump(proposal), 200 + + return {"message": "No milestone matching id"}, 404 # EMAIL diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py index 5dd1ac2b..37d0254b 100644 --- a/backend/grant/email/send.py +++ b/backend/grant/email/send.py @@ -163,6 +163,52 @@ def proposal_arbiter(email_args): } +def milestone_request(email_args): + p = email_args['proposal'] + ms = p.current_milestone + return { + 'subject': f'Payout request for {p.title} - {ms.title} has been made', + 'title': f'Milestone payout requested', + 'preview': f'A payout request for milestone {ms.title} has been made.', + 'subscription': EmailSubscription.ARBITER, + } + + +def milestone_reject(email_args): + p = email_args['proposal'] + ms = p.current_milestone + return { + 'subject': f'Payout rejected for {p.title} - {ms.title}', + 'title': f'Milestone payout rejected', + 'preview': f'The payout for milestone {ms.title} has been rejected.', + 'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL, + } + + +def milestone_accept(email_args): + p = email_args['proposal'] + a = email_args['amount'] + ms = p.current_milestone + return { + 'subject': f'Payout approved for {p.title} - {ms.title}!', + 'title': f'Milestone payout approved', + 'preview': f'The payout of {a} ZEC for milestone {ms.title} has been approved.', + 'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL, + } + + +def milestone_paid(email_args): + p = email_args['proposal'] + a = email_args['amount'] + ms = p.current_milestone + return { + 'subject': f'{p.title} - {ms.title} has been paid!', + 'title': f'Milestone paid', + 'preview': f'The milestone {ms.title} payout of {a} ZEC has been paid!', + 'subscription': EmailSubscription.MY_PROPOSAL_FUNDED, + } + + get_info_lookup = { 'signup': signup_info, 'team_invite': team_invite_info, @@ -178,7 +224,11 @@ get_info_lookup = { 'contribution_confirmed': contribution_confirmed, 'contribution_update': contribution_update, 'comment_reply': comment_reply, - 'proposal_arbiter': proposal_arbiter + 'proposal_arbiter': proposal_arbiter, + 'milestone_request': milestone_request, + 'milestone_reject': milestone_reject, + 'milestone_accept': milestone_accept, + 'milestone_paid': milestone_paid } diff --git a/backend/grant/milestone/models.py b/backend/grant/milestone/models.py index e1f62d12..c6f9b02d 100644 --- a/backend/grant/milestone/models.py +++ b/backend/grant/milestone/models.py @@ -2,39 +2,55 @@ import datetime from grant.extensions import ma, db from grant.utils.exceptions import ValidationException -from grant.utils.misc import dt_to_unix +from grant.utils.ma_fields import UnixDate +from grant.utils.enums import MilestoneStage -NOT_REQUESTED = 'NOT_REQUESTED' -ONGOING_VOTE = 'ONGOING_VOTE' -PAID = 'PAID' -MILESTONE_STAGES = [NOT_REQUESTED, ONGOING_VOTE, PAID] + +class MilestoneException(Exception): + pass class Milestone(db.Model): __tablename__ = "milestone" id = db.Column(db.Integer(), primary_key=True) + index = db.Column(db.Integer(), nullable=False) date_created = db.Column(db.DateTime, nullable=False) title = db.Column(db.String(255), nullable=False) content = db.Column(db.Text, nullable=False) - stage = db.Column(db.String(255), nullable=False) payout_percent = db.Column(db.String(255), nullable=False) immediate_payout = db.Column(db.Boolean) - + # TODO: change to estimated_duration (sec or ms) -- FE can calc from dates on draft date_estimated = db.Column(db.DateTime, nullable=False) + stage = db.Column(db.String(255), nullable=False) + + date_requested = db.Column(db.DateTime, nullable=True) + requested_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + + date_rejected = db.Column(db.DateTime, nullable=True) + reject_reason = db.Column(db.String(255)) + reject_arbiter_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + + date_accepted = db.Column(db.DateTime, nullable=True) + accept_arbiter_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + + date_paid = db.Column(db.DateTime, nullable=True) + paid_tx_id = db.Column(db.String(255)) + proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False) def __init__( self, + index: int, title: str, content: str, date_estimated: datetime, payout_percent: str, immediate_payout: bool, - stage: str = NOT_REQUESTED, - proposal_id=int + stage: str = MilestoneStage.IDLE, + proposal_id=int, ): self.title = title self.content = content @@ -44,12 +60,42 @@ class Milestone(db.Model): self.immediate_payout = immediate_payout self.proposal_id = proposal_id self.date_created = datetime.datetime.now() + self.index = index @staticmethod def validate(milestone): if len(milestone.title) > 60: raise ValidationException("Milestone title must be no more than 60 chars") + def request_payout(self, user_id: int): + if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]: + raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage') + self.stage = MilestoneStage.REQUESTED + self.date_requested = datetime.datetime.now() + self.requested_user_id = user_id + + def reject_request(self, arbiter_id: int, reason: str): + if self.stage != MilestoneStage.REQUESTED: + raise MilestoneException(f'Cannot reject payout request for milestone at {self.stage} stage') + self.stage = MilestoneStage.REJECTED + self.date_rejected = datetime.datetime.now() + self.reject_reason = reason + self.reject_arbiter_id = arbiter_id + + def accept_request(self, arbiter_id: int): + if self.stage != MilestoneStage.REQUESTED: + raise MilestoneException(f'Cannot accept payout request for milestone at {self.stage} stage') + self.stage = MilestoneStage.ACCEPTED + self.date_accepted = datetime.datetime.now() + self.accept_arbiter_id = arbiter_id + + def mark_paid(self, tx_id: str): + if self.stage != MilestoneStage.ACCEPTED: + raise MilestoneException(f'Cannot pay a milestone at {self.stage} stage') + self.stage = MilestoneStage.PAID + self.date_paid = datetime.datetime.now() + self.paid_tx_id = tx_id + class MilestoneSchema(ma.Schema): class Meta: @@ -57,22 +103,28 @@ class MilestoneSchema(ma.Schema): # Fields to expose fields = ( "title", + "index", + "id", "content", "stage", - "date_estimated", "payout_percent", "immediate_payout", + "reject_reason", + "paid_tx_id", "date_created", + "date_estimated", + "date_requested", + "date_rejected", + "date_accepted", + "date_paid", ) - date_created = ma.Method("get_date_created") - date_estimated = ma.Method("get_date_estimated") - - def get_date_created(self, obj): - return dt_to_unix(obj.date_created) - - def get_date_estimated(self, obj): - return dt_to_unix(obj.date_estimated) if obj.date_estimated else None + date_created = UnixDate(attribute='date_created') + date_estimated = UnixDate(attribute='date_estimated') + date_requested = UnixDate(attribute='date_requested') + date_rejected = UnixDate(attribute='date_rejected') + date_accepted = UnixDate(attribute='date_accepted') + date_paid = UnixDate(attribute='date_paid') milestone_schema = MilestoneSchema() diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 36d42316..8243d959 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -10,8 +10,15 @@ from grant.extensions import ma, db from grant.utils.exceptions import ValidationException from grant.utils.misc import dt_to_unix, make_url from grant.utils.requests import blockchain_get -from grant.utils.enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus from grant.settings import PROPOSAL_STAKING_AMOUNT +from grant.utils.enums import ( + ProposalStatus, + ProposalStage, + Category, + ContributionStatus, + ProposalArbiterStatus, + MilestoneStage +) proposal_team = db.Table( 'proposal_team', db.Model.metadata, @@ -214,7 +221,8 @@ class Proposal(db.Model): comments = db.relationship(Comment, backref="proposal", lazy=True, cascade="all, delete-orphan") updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True, cascade="all, delete-orphan") contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True, cascade="all, delete-orphan") - milestones = db.relationship("Milestone", backref="proposal", lazy=True, cascade="all, delete-orphan") + milestones = db.relationship("Milestone", backref="proposal", + order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan") invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan") arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan") @@ -224,7 +232,7 @@ class Proposal(db.Model): title: str = '', brief: str = '', content: str = '', - stage: str = '', + stage: str = ProposalStage.PREVIEW, target: str = '0', payout_address: str = '', deadline_duration: int = 5184000, # 60 days @@ -394,6 +402,7 @@ class Proposal(db.Model): self.date_published = datetime.datetime.now() self.status = ProposalStatus.LIVE + self.stage = ProposalStage.FUNDING_REQUIRED @hybrid_property def contributed(self): @@ -418,6 +427,19 @@ class Proposal(db.Model): def is_staked(self): return Decimal(self.contributed) >= PROPOSAL_STAKING_AMOUNT + @hybrid_property + def is_funded(self): + return Decimal(self.contributed) >= Decimal(self.target) + + @hybrid_property + def current_milestone(self): + if self.milestones: + for ms in self.milestones: + if ms.stage != MilestoneStage.PAID: + return ms + return self.milestones[-1] # return last one if all PAID + return None + class ProposalSchema(ma.Schema): class Meta: @@ -441,6 +463,7 @@ class ProposalSchema(ma.Schema): "comments", "updates", "milestones", + "current_milestone", "category", "team", "payout_address", @@ -460,6 +483,7 @@ class ProposalSchema(ma.Schema): updates = ma.Nested("ProposalUpdateSchema", many=True) team = ma.Nested("UserSchema", many=True) milestones = ma.Nested("MilestoneSchema", many=True) + current_milestone = ma.Nested("MilestoneSchema") invites = ma.Nested("ProposalTeamInviteSchema", many=True) rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"]) arbiter = ma.Nested("ProposalArbiterSchema", exclude=["proposal"]) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index ca11e2dc..5718c41c 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -1,4 +1,5 @@ from dateutil.parser import parse +from decimal import Decimal from flask import Blueprint, g, request from flask_yoloapi import endpoint, parameter from grant.comment.models import Comment, comment_schema, comments_schema @@ -10,13 +11,14 @@ from grant.rfp.models import RFP from grant.utils.auth import ( requires_auth, requires_team_member_auth, + requires_arbiter_auth, requires_email_verified_auth, get_authed_user, internal_webhook ) from grant.utils.exceptions import ValidationException from grant.utils.misc import is_email, make_url, from_zat -from grant.utils.enums import ProposalStatus, ContributionStatus +from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus from grant.utils import pagination from sqlalchemy import or_ from datetime import datetime @@ -213,14 +215,15 @@ def update_proposal(milestones, proposal_id, **kwargs): # Delete & re-add milestones [db.session.delete(x) for x in g.current_proposal.milestones] if milestones: - for mdata in milestones: + for i, mdata in enumerate(milestones): m = Milestone( title=mdata["title"], content=mdata["content"], date_estimated=datetime.fromtimestamp(mdata["dateEstimated"]), payout_percent=str(mdata["payoutPercent"]), immediate_payout=mdata["immediatePayout"], - proposal_id=g.current_proposal.id + proposal_id=g.current_proposal.id, + index=i ) db.session.add(m) @@ -480,14 +483,14 @@ def post_contribution_confirmation(contribution_id, to, amount, txid): contribution.confirm(tx_id=txid, amount=zec_amount) db.session.add(contribution) - db.session.commit() + db.session.flush() if contribution.proposal.status == ProposalStatus.STAKING: - # fully staked, set status PENDING & notify user + # fully staked, set status PENDING if contribution.proposal.is_staked: # Decimal(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT: contribution.proposal.status = ProposalStatus.PENDING db.session.add(contribution.proposal) - db.session.commit() + db.session.flush() # email progress of staking, partial or complete send_email(contribution.user.email_address, 'staking_contribution_confirmed', { @@ -519,7 +522,13 @@ def post_contribution_confirmation(contribution_id, to, amount, txid): # TODO: Once we have a task queuer in place, queue emails to everyone # on funding target reached. + if contribution.proposal.status == ProposalStatus.LIVE: + if contribution.proposal.is_funded: + contribution.proposal.stage = ProposalStage.WIP + db.session.add(contribution.proposal) + db.session.flush() + db.session.commit() return None, 200 @@ -542,3 +551,76 @@ def delete_proposal_contribution(contribution_id): db.session.add(contribution) db.session.commit() return None, 202 + + +# request MS payout +@blueprint.route("//milestone//request", methods=["PUT"]) +@requires_team_member_auth +@endpoint.api() +def request_milestone_payout(proposal_id, milestone_id): + if not g.current_proposal.is_funded: + return {"message": "Proposal is not fully funded"}, 400 + for ms in g.current_proposal.milestones: + if ms.id == int(milestone_id): + ms.request_payout(g.current_user.id) + db.session.add(ms) + db.session.commit() + # email ARBITER to review payout request + send_email(g.current_proposal.arbiter.user.email_address, 'milestone_request', { + 'proposal': g.current_proposal, + 'proposal_milestones_url': make_url(f'/proposals/{g.current_proposal.id}?tab=milestones'), + }) + return proposal_schema.dump(g.current_proposal), 200 + + return {"message": "No milestone matching id"}, 404 + + +# accept MS payout (arbiter) +@blueprint.route("//milestone//accept", methods=["PUT"]) +@requires_arbiter_auth +@endpoint.api() +def accept_milestone_payout_request(proposal_id, milestone_id): + if not g.current_proposal.is_funded: + return {"message": "Proposal is not fully funded"}, 400 + for ms in g.current_proposal.milestones: + if ms.id == int(milestone_id): + ms.accept_request(g.current_user.id) + db.session.add(ms) + db.session.commit() + # email TEAM that payout request accepted + amount = Decimal(ms.payout_percent) * Decimal(g.current_proposal.target) / 100 + for member in g.current_proposal.team: + send_email(member.email_address, 'milestone_accept', { + 'proposal': g.current_proposal, + 'amount': amount, + 'proposal_milestones_url': make_url(f'/proposals/{g.current_proposal.id}?tab=milestones'), + }) + return proposal_schema.dump(g.current_proposal), 200 + + return {"message": "No milestone matching id"}, 404 + + +# reject MS payout (arbiter) (reason) +@blueprint.route("//milestone//reject", methods=["PUT"]) +@requires_arbiter_auth +@endpoint.api( + parameter('reason', type=str, required=True), +) +def reject_milestone_payout_request(proposal_id, milestone_id, reason): + if not g.current_proposal.is_funded: + return {"message": "Proposal is not fully funded"}, 400 + for ms in g.current_proposal.milestones: + if ms.id == int(milestone_id): + ms.reject_request(g.current_user.id, reason) + db.session.add(ms) + db.session.commit() + # email TEAM that payout request was rejected + for member in g.current_proposal.team: + send_email(member.email_address, 'milestone_reject', { + 'proposal': g.current_proposal, + 'admin_note': reason, + 'proposal_milestones_url': make_url(f'/proposals/{g.current_proposal.id}?tab=milestones'), + }) + return proposal_schema.dump(g.current_proposal), 200 + + return {"message": "No milestone matching id"}, 404 diff --git a/backend/grant/templates/emails/milestone_accept.html b/backend/grant/templates/emails/milestone_accept.html new file mode 100644 index 00000000..9eb78267 --- /dev/null +++ b/backend/grant/templates/emails/milestone_accept.html @@ -0,0 +1,11 @@ +

+ The proposal milestone + + {{ args.proposal.title }} - {{ args.proposal.current_milestone.title }} + + payout of {{ args.amount }} ZEC has been approved. +

+ +

+ You will receive payment shortly! +

diff --git a/backend/grant/templates/emails/milestone_accept.txt b/backend/grant/templates/emails/milestone_accept.txt new file mode 100644 index 00000000..a087f88d --- /dev/null +++ b/backend/grant/templates/emails/milestone_accept.txt @@ -0,0 +1,6 @@ +The proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}" +payout of {{args.amount}} ZEC has been approved! + +You will receive payment shortly! + +View the milestone: {{ args.proposal_milestones_url }} diff --git a/backend/grant/templates/emails/milestone_paid.html b/backend/grant/templates/emails/milestone_paid.html new file mode 100644 index 00000000..f5d0aaf6 --- /dev/null +++ b/backend/grant/templates/emails/milestone_paid.html @@ -0,0 +1,12 @@ +

+ Hooray! {{ args.amount }} ZEC has been paid out for + + {{ args.proposal.title }} - {{ args.proposal.current_milestone.title }} ! You can view the transaction below: +

+ +

+ + {{ args.tx_explorer_url }} + +

diff --git a/backend/grant/templates/emails/milestone_paid.txt b/backend/grant/templates/emails/milestone_paid.txt new file mode 100644 index 00000000..d2177a85 --- /dev/null +++ b/backend/grant/templates/emails/milestone_paid.txt @@ -0,0 +1,6 @@ +Hooray! {{args.amount}} ZEC has been paid out for "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}"! +You can view the transaction below: + +{{ args.tx_explorer_url }} + +View the milestone: {{ args.proposal_milestones_url }} diff --git a/backend/grant/templates/emails/milestone_reject.html b/backend/grant/templates/emails/milestone_reject.html new file mode 100644 index 00000000..31bb42b3 --- /dev/null +++ b/backend/grant/templates/emails/milestone_reject.html @@ -0,0 +1,21 @@ +

+ The payout request for proposal milestone + + {{ args.proposal.title }} - {{ args.proposal.current_milestone.title }} + + has been rejected. +

+ +{% if args.admin_note %} +

+ The following reason was provided: +

+

+ “{{ args.admin_note }}” +

+{% endif %} + +

+ Another request for payment can be made when the above concerns have been + addressed. +

diff --git a/backend/grant/templates/emails/milestone_reject.txt b/backend/grant/templates/emails/milestone_reject.txt new file mode 100644 index 00000000..ac6f2caf --- /dev/null +++ b/backend/grant/templates/emails/milestone_reject.txt @@ -0,0 +1,12 @@ +The payout request for proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}" +has been rejected. + +{% if args.admin_note %} +The following reason was provided: + +> {{ args.admin_note }} +{% endif %} + +Another request for payment can be made when the above concerns have been addressed. + +View milestone: {{ args.proposal_milestones_url }} diff --git a/backend/grant/templates/emails/milestone_request.html b/backend/grant/templates/emails/milestone_request.html new file mode 100644 index 00000000..4b0c5ef7 --- /dev/null +++ b/backend/grant/templates/emails/milestone_request.html @@ -0,0 +1,33 @@ +

+ A payout request for the proposal milestone + + {{ args.proposal.title }} - {{ args.proposal.current_milestone.title }} + + has been made. As arbiter, you are responsible for reviewing this request. +

+ + + + + +
+ + + + +
+ + Review the request + +
+
diff --git a/backend/grant/templates/emails/milestone_request.txt b/backend/grant/templates/emails/milestone_request.txt new file mode 100644 index 00000000..3592a4ab --- /dev/null +++ b/backend/grant/templates/emails/milestone_request.txt @@ -0,0 +1,4 @@ +A payout request for the proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}" +has been made. As arbiter, you are responsible for reviewing this request. + +Review the request: {{ args.proposal_milestones_url }} diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index 324ecb5e..08611bda 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -76,6 +76,26 @@ def requires_team_member_auth(f): return requires_email_verified_auth(decorated) +def requires_arbiter_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + proposal_id = kwargs["proposal_id"] + if not proposal_id: + return jsonify(message="Decorator requires_arbiter_auth requires path variable "), 500 + + proposal = Proposal.query.filter_by(id=proposal_id).first() + if not proposal: + return jsonify(message="No proposal exists with id {}".format(proposal_id)), 404 + + if g.current_user != proposal.arbiter.user: + return jsonify(message="You are not arbiter this proposal"), 403 + + g.current_proposal = proposal + return f(*args, **kwargs) + + return requires_email_verified_auth(decorated) + + def internal_webhook(f): @wraps(f) def decorated(*args, **kwargs): diff --git a/backend/grant/utils/enums.py b/backend/grant/utils/enums.py index 85a10c97..41b6a3e1 100644 --- a/backend/grant/utils/enums.py +++ b/backend/grant/utils/enums.py @@ -33,7 +33,9 @@ ProposalSort = ProposalSortEnum() class ProposalStageEnum(CustomEnum): + PREVIEW = 'PREVIEW' FUNDING_REQUIRED = 'FUNDING_REQUIRED' + WIP = 'WIP' COMPLETED = 'COMPLETED' @@ -69,6 +71,17 @@ class RFPStatusEnum(CustomEnum): RFPStatus = RFPStatusEnum() +class MilestoneStageEnum(CustomEnum): + IDLE = 'IDLE' + REQUESTED = 'REQUESTED' + REJECTED = 'REJECTED' + ACCEPTED = 'ACCEPTED' + PAID = 'PAID' + + +MilestoneStage = MilestoneStageEnum() + + class ProposalArbiterStatusEnum(CustomEnum): MISSING = 'MISSING' NOMINATED = 'NOMINATED' diff --git a/backend/grant/utils/ma_fields.py b/backend/grant/utils/ma_fields.py new file mode 100644 index 00000000..3cc19469 --- /dev/null +++ b/backend/grant/utils/ma_fields.py @@ -0,0 +1,7 @@ +from grant.extensions import ma +from .misc import dt_to_unix + + +class UnixDate(ma.Field): + def _serialize(self, value, attr, obj, **kwargs): + return dt_to_unix(value) if value else None diff --git a/backend/grant/utils/pagination.py b/backend/grant/utils/pagination.py index d75c50e8..39c45194 100644 --- a/backend/grant/utils/pagination.py +++ b/backend/grant/utils/pagination.py @@ -2,7 +2,8 @@ import abc from sqlalchemy import or_, and_ from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema -from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus +from grant.milestone.models import Milestone +from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage def extract_filters(sw, strings): @@ -50,6 +51,7 @@ class ProposalPagination(Pagination): self.FILTERS.extend([f'STAGE_{s}' for s in ProposalStage.list()]) self.FILTERS.extend([f'CAT_{c}' for c in Category.list()]) self.FILTERS.extend([f'ARBITER_{c}' for c in ProposalArbiterStatus.list()]) + self.FILTERS.extend([f'MILESTONE_{c}' for c in MilestoneStage.list()]) self.PAGE_SIZE = 9 self.SORT_MAP = { 'CREATED:DESC': Proposal.date_created.desc(), @@ -77,6 +79,7 @@ class ProposalPagination(Pagination): stage_filters = extract_filters('STAGE_', filters) cat_filters = extract_filters('CAT_', filters) arbiter_filters = extract_filters('ARBITER_', filters) + milestone_filters = extract_filters('MILESTONE_', filters) if status_filters: query = query.filter(Proposal.status.in_(status_filters)) @@ -89,6 +92,9 @@ class ProposalPagination(Pagination): if arbiter_filters: query = query.join(Proposal.arbiter) \ .filter(ProposalArbiter.status.in_(arbiter_filters)) + if milestone_filters: + query = query.join(Proposal.milestones) \ + .filter(Milestone.stage.in_(milestone_filters)) # SORT (see self.SORT_MAP) if sort: diff --git a/backend/migrations/versions/3793d9a71e27_.py b/backend/migrations/versions/3793d9a71e27_.py new file mode 100644 index 00000000..1dc4bfce --- /dev/null +++ b/backend/migrations/versions/3793d9a71e27_.py @@ -0,0 +1,52 @@ +"""milestone payment fields + +Revision ID: 3793d9a71e27 +Revises: 86d300cb6d69 +Create Date: 2019-02-11 11:01:44.703413 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3793d9a71e27' +down_revision = '86d300cb6d69' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('milestone', sa.Column('accept_arbiter_id', sa.Integer(), nullable=True)) + op.add_column('milestone', sa.Column('date_accepted', sa.DateTime(), nullable=True)) + op.add_column('milestone', sa.Column('date_paid', sa.DateTime(), nullable=True)) + op.add_column('milestone', sa.Column('date_rejected', sa.DateTime(), nullable=True)) + op.add_column('milestone', sa.Column('date_requested', sa.DateTime(), nullable=True)) + op.add_column('milestone', sa.Column('index', sa.Integer(), nullable=False)) + op.add_column('milestone', sa.Column('paid_tx_id', sa.String(length=255), nullable=True)) + op.add_column('milestone', sa.Column('reject_arbiter_id', sa.Integer(), nullable=True)) + op.add_column('milestone', sa.Column('reject_reason', sa.String(length=255), nullable=True)) + op.add_column('milestone', sa.Column('requested_user_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'milestone', 'user', ['accept_arbiter_id'], ['id']) + op.create_foreign_key(None, 'milestone', 'user', ['reject_arbiter_id'], ['id']) + op.create_foreign_key(None, 'milestone', 'user', ['requested_user_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'milestone', type_='foreignkey') + op.drop_constraint(None, 'milestone', type_='foreignkey') + op.drop_constraint(None, 'milestone', type_='foreignkey') + op.drop_column('milestone', 'requested_user_id') + op.drop_column('milestone', 'reject_reason') + op.drop_column('milestone', 'reject_arbiter_id') + op.drop_column('milestone', 'paid_tx_id') + op.drop_column('milestone', 'index') + op.drop_column('milestone', 'date_requested') + op.drop_column('milestone', 'date_rejected') + op.drop_column('milestone', 'date_paid') + op.drop_column('milestone', 'date_accepted') + op.drop_column('milestone', 'accept_arbiter_id') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/86d300cb6d69_.py b/backend/migrations/versions/86d300cb6d69_.py index 9797292e..cfea18ef 100644 --- a/backend/migrations/versions/86d300cb6d69_.py +++ b/backend/migrations/versions/86d300cb6d69_.py @@ -1,4 +1,4 @@ -"""empty message +"""proposal_arbiter table Revision ID: 86d300cb6d69 Revises: 310dca400b81 @@ -17,23 +17,23 @@ depends_on = None def upgrade(): -# ### commands auto generated by Alembic - please adjust! ### + # ### commands auto generated by Alembic - please adjust! ### op.create_table('proposal_arbiter', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('proposal_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=True), - sa.Column('status', sa.String(length=255), nullable=False), - sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), - sa.PrimaryKeyConstraint('id') - ) + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('proposal_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('status', sa.String(length=255), nullable=False), + sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) op.drop_constraint('proposal_arbiter_id_fkey', 'proposal', type_='foreignkey') op.drop_column('proposal', 'arbiter_id') # ### end Alembic commands ### def downgrade(): -# ### commands auto generated by Alembic - please adjust! ### + # ### commands auto generated by Alembic - please adjust! ### op.add_column('proposal', sa.Column('arbiter_id', sa.INTEGER(), autoincrement=False, nullable=True)) op.create_foreign_key('proposal_arbiter_id_fkey', 'proposal', 'user', ['arbiter_id'], ['id']) op.drop_table('proposal_arbiter') diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 0fc01e25..e4abf96c 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -228,6 +228,41 @@ export async function putProposalPublish( }); } +export async function requestProposalPayout( + proposalId: number, + milestoneId: number, +): Promise<{ data: Proposal }> { + return axios + .put(`/api/v1/proposals/${proposalId}/milestone/${milestoneId}/request`) + .then(res => { + res.data = formatProposalFromGet(res.data); + return res; + }); +} +export async function acceptProposalPayout( + proposalId: number, + milestoneId: number, +): Promise<{ data: Proposal }> { + return axios + .put(`/api/v1/proposals/${proposalId}/milestone/${milestoneId}/accept`) + .then(res => { + res.data = formatProposalFromGet(res.data); + return res; + }); +} +export async function rejectProposalPayout( + proposalId: number, + milestoneId: number, + reason: string, +): Promise<{ data: Proposal }> { + return axios + .put(`/api/v1/proposals/${proposalId}/milestone/${milestoneId}/reject`, { reason }) + .then(res => { + res.data = formatProposalFromGet(res.data); + return res; + }); +} + export function postProposalInvite( proposalId: number, address: string, diff --git a/frontend/client/api/constants.ts b/frontend/client/api/constants.ts index 249ac186..49226af4 100644 --- a/frontend/client/api/constants.ts +++ b/frontend/client/api/constants.ts @@ -51,6 +51,7 @@ export const CATEGORY_UI: { [key in PROPOSAL_CATEGORY]: CategoryUI } = { }; export enum PROPOSAL_STAGE { + PREVIEW = 'PREVIEW', FUNDING_REQUIRED = 'FUNDING_REQUIRED', WIP = 'WIP', COMPLETED = 'COMPLETED', @@ -62,6 +63,10 @@ interface StageUI { } export const STAGE_UI: { [key in PROPOSAL_STAGE]: StageUI } = { + PREVIEW: { + label: 'Preview', + color: '#8e44ad', + }, FUNDING_REQUIRED: { label: 'Funding required', color: '#8e44ad', diff --git a/frontend/client/components/Profile/ProfileArbitrated.tsx b/frontend/client/components/Profile/ProfileArbitrated.tsx index 191eec30..c1b31a8e 100644 --- a/frontend/client/components/Profile/ProfileArbitrated.tsx +++ b/frontend/client/components/Profile/ProfileArbitrated.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { UserProposalArbiter, PROPOSAL_ARBITER_STATUS } from 'types'; +import moment from 'moment'; +import { UserProposalArbiter, PROPOSAL_ARBITER_STATUS, MILESTONE_STAGE } from 'types'; import { connect } from 'react-redux'; import { AppState } from 'store/reducers'; import { updateUserArbiter } from 'api/api'; @@ -27,15 +28,32 @@ type Props = OwnProps & StateProps & DispatchProps; class ProfileArbitrated extends React.Component { render() { const { status } = this.props.arbiter; - const { title, proposalId } = this.props.arbiter.proposal; + const { title, proposalId, currentMilestone } = this.props.arbiter.proposal; + const isMsPayoutReq = + currentMilestone && currentMilestone.stage === MILESTONE_STAGE.REQUESTED; + const msTitle = currentMilestone && currentMilestone.title; const info = { [PAS.MISSING]: <>{/* nada */}, [PAS.NOMINATED]: <>You have been nominated to be the arbiter for this proposal., [PAS.ACCEPTED]: ( <> - As arbiter of this proposal, you are responsible for reviewing milestone payout - requests. You may{' '} + {isMsPayoutReq && ( + <> + The team has requested payout for {msTitle}{' '} + {moment((currentMilestone!.dateRequested || 0) * 1000).fromNow()}. Please + click the button to proceed. + + )} + {!isMsPayoutReq && ( + <> + As arbiter of this proposal, you are responsible for reviewing milestone + payout requests.{' '} + + )} +
+
+ You may{' '} this.acceptArbiter(false)} @@ -57,7 +75,15 @@ class ProfileArbitrated extends React.Component { ), - [PAS.ACCEPTED]: <>{/* TODO - milestone payout approvals */}, + [PAS.ACCEPTED]: ( + <> + {isMsPayoutReq && ( + + + + )} + + ), }; return ( diff --git a/frontend/client/components/Proposal/Milestones/style.less b/frontend/client/components/Proposal/Milestones/index.less similarity index 87% rename from frontend/client/components/Proposal/Milestones/style.less rename to frontend/client/components/Proposal/Milestones/index.less index 47e7de1b..e2a9ecae 100644 --- a/frontend/client/components/Proposal/Milestones/style.less +++ b/frontend/client/components/Proposal/Milestones/index.less @@ -39,6 +39,11 @@ margin-left: 0; } + &-alert { + width: fit-content; + margin: 0 0 1rem 0; + } + &-title { display: none; white-space: nowrap; @@ -97,6 +102,31 @@ flex: 1; } + &-action { + q { + display: block; + margin-bottom: 0.5rem; + background: rgba(0, 0, 0, 0.06); + padding: 0.5rem; + } + + h3 { + font-size: 1rem; + text-align: center; + } + + &-controls { + display: flex; + + & > * { + flex-grow: 1; + } + & > * + * { + margin-left: 0.5rem; + } + } + } + &-divider { width: 1px; background: rgba(0, 0, 0, 0.05); diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index 9fbe18b4..348729d1 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -1,17 +1,27 @@ -import lodash from 'lodash'; -import React from 'react'; +import { throttle } from 'lodash'; +import React, { ReactNode } from 'react'; import moment from 'moment'; -import { Alert, Steps } from 'antd'; -import { Proposal, MILESTONE_STATE } from 'types'; +import classnames from 'classnames'; +import { connect } from 'react-redux'; +import { Alert, Steps, Button, message, Modal, Input } from 'antd'; +import { AlertProps } from 'antd/lib/alert'; +import { StepProps } from 'antd/lib/steps'; +import TextArea from 'antd/lib/input/TextArea'; +import { + Milestone, + ProposalMilestone, + MILESTONE_STAGE, + PROPOSAL_ARBITER_STATUS, +} from 'types'; +import { PROPOSAL_STAGE } from 'api/constants'; import UnitDisplay from 'components/UnitDisplay'; import Loader from 'components/Loader'; import { AppState } from 'store/reducers'; -import { connect } from 'react-redux'; -import classnames from 'classnames'; -import './style.less'; import Placeholder from 'components/Placeholder'; - -const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE; +import { proposalActions } from 'modules/proposals'; +import { ProposalDetail } from 'modules/proposals/reducers'; +import './index.less'; +import { Link } from 'react-router-dom'; enum STEP_STATUS { WAIT = 'wait', @@ -20,53 +30,68 @@ enum STEP_STATUS { ERROR = 'error', } -const milestoneStateToStepState = { - [WAITING]: STEP_STATUS.WAIT, - [ACTIVE]: STEP_STATUS.PROCESS, - [PAID]: STEP_STATUS.FINISH, - [REJECTED]: STEP_STATUS.ERROR, -}; +const milestoneStageToStepState = { + [MILESTONE_STAGE.IDLE]: STEP_STATUS.WAIT, + [MILESTONE_STAGE.REQUESTED]: STEP_STATUS.PROCESS, + [MILESTONE_STAGE.ACCEPTED]: STEP_STATUS.PROCESS, + [MILESTONE_STAGE.REJECTED]: STEP_STATUS.ERROR, + [MILESTONE_STAGE.ACCEPTED]: STEP_STATUS.FINISH, +} as { [key in MILESTONE_STAGE]: StepProps['status'] }; + +const fmtDate = (n: undefined | number) => + (n && moment(n * 1000).format('MMM Do, YYYY, h:mm a')) || undefined; + +const fmtDateFromNow = (n: undefined | number) => (n && moment(n * 1000).fromNow()) || ''; interface OwnProps { - proposal: Proposal; + proposal: ProposalDetail; } -interface StateProps { - accounts: string[]; +interface DispatchProps { + requestPayout: typeof proposalActions.requestPayout; + acceptPayout: typeof proposalActions.acceptPayout; + rejectPayout: typeof proposalActions.rejectPayout; } -type Props = OwnProps & StateProps; +type Props = OwnProps & DispatchProps; interface State { step: number; activeMilestoneIdx: number; doTitlesOverflow: boolean; + showRejectModal: boolean; + rejectReason: string; + rejectMilestoneId: number; } class ProposalMilestones extends React.Component { stepTitleRefs: Array> = []; ref: React.RefObject; + rejectInput: null | TextArea; throttledUpdateDoTitlesOverflow: () => void; + constructor(props: Props) { super(props); + this.rejectInput = null; this.stepTitleRefs = this.props.proposal.milestones.map(() => React.createRef()); this.ref = React.createRef(); - this.throttledUpdateDoTitlesOverflow = lodash.throttle( - this.updateDoTitlesOverflow, - 500, - ); + this.throttledUpdateDoTitlesOverflow = throttle(this.updateDoTitlesOverflow, 500); + const step = + (this.props.proposal && + this.props.proposal.currentMilestone && + this.props.proposal.currentMilestone.index) || + 0; this.state = { - step: 0, + step, activeMilestoneIdx: 0, doTitlesOverflow: true, + showRejectModal: false, + rejectReason: '', + rejectMilestoneId: -1, }; } componentDidMount() { - if (this.props.proposal) { - const activeMilestoneIdx = this.getActiveMilestoneIdx(); - this.setState({ step: activeMilestoneIdx, activeMilestoneIdx }); - } this.updateDoTitlesOverflow(); window.addEventListener('resize', this.throttledUpdateDoTitlesOverflow); } @@ -74,123 +99,116 @@ class ProposalMilestones extends React.Component { window.removeEventListener('resize', this.throttledUpdateDoTitlesOverflow); } - componentDidUpdate(_: Props, prevState: State) { - const activeMilestoneIdx = this.getActiveMilestoneIdx(); - if (prevState.activeMilestoneIdx !== activeMilestoneIdx) { - this.setState({ step: activeMilestoneIdx, activeMilestoneIdx }); + componentDidUpdate(prevProps: Props, _: State) { + const cm = this.props.proposal.currentMilestone; + const pcm = prevProps.proposal.currentMilestone; + const cmId = (cm && cm.id) || 0; + const pcmId = (pcm && pcm.id) || 0; + if (pcmId !== cmId) { + this.setState({ step: (cm && cm.index) || 0 }); + } + const { + requestPayoutError, + isRequestingPayout, + acceptPayoutError, + isAcceptingPayout, + rejectPayoutError, + isRejectingPayout, + } = this.props.proposal; + + if (!prevProps.proposal.requestPayoutError && requestPayoutError) { + message.error(requestPayoutError); + } + if ( + prevProps.proposal.isRequestingPayout && + !isRequestingPayout && + !requestPayoutError + ) { + message.success('Payout requested.'); + } + + if (!prevProps.proposal.acceptPayoutError && acceptPayoutError) { + message.error(acceptPayoutError); + } + if ( + prevProps.proposal.isAcceptingPayout && + !isAcceptingPayout && + !acceptPayoutError + ) { + message.success('Payout approved.'); + } + + if (!prevProps.proposal.rejectPayoutError && rejectPayoutError) { + message.error(rejectPayoutError); + } + if ( + prevProps.proposal.isRejectingPayout && + !isRejectingPayout && + !rejectPayoutError + ) { + message.info('Payout rejected.'); } } render() { - const { proposal } = this.props; + const { proposal, requestPayout, acceptPayout, rejectPayout } = this.props; + const { rejectReason, showRejectModal } = this.state; if (!proposal) { return ; } - const { milestones } = proposal; - - const isTrustee = false; // TODO: Replace with being on the team + const { milestones, currentMilestone, isRejectingPayout } = proposal; const milestoneCount = milestones.length; - const milestoneSteps = milestones.map((milestone, i) => { + // arbiter reject modal + const rejectModal = ( + this.setState({ showRejectModal: false })} + okButtonProps={{ + disabled: rejectReason.length === 0, + loading: isRejectingPayout, + }} + cancelButtonProps={{ + loading: isRejectingPayout, + }} + > + Please provide a reason: + (this.rejectInput = ta)} + rows={4} + maxLength={250} + required={true} + value={rejectReason} + onChange={e => { + this.setState({ rejectReason: e.target.value }); + }} + /> + + ); + + // generate steps + const milestoneSteps = milestones.map((ms, i) => { const status = - this.state.activeMilestoneIdx === i && milestone.state === WAITING + currentMilestone && + currentMilestone.index === i && + ms.stage === MILESTONE_STAGE.IDLE ? STEP_STATUS.PROCESS - : milestoneStateToStepState[milestone.state]; - + : milestoneStageToStepState[ms.stage]; const className = this.state.step === i ? 'is-active' : 'is-inactive'; - const estimatedDate = moment(milestone.dateEstimated * 1000).format('MMMM YYYY'); - const reward = ( - - ); - const alertStyle = { width: 'fit-content', margin: '0 0 1rem 0' }; - const stepProps = { - title:
{milestone.title}
, + title:
{ms.title}
, status, className, onClick: () => this.setState({ step: i }), }; - - let notification; - - switch (milestone.state) { - case PAID: - notification = ( - - The team was awarded {reward}{' '} - {milestone.immediatePayout - ? 'as an initial payout' - : // TODO: Add property for payout date on milestones - `on ${moment().format('MMM Do, YYYY')}`} - . - - } - style={alertStyle} - /> - ); - break; - case ACTIVE: - notification = ( - - ); - break; - case REJECTED: - notification = ( - - Payout for this milestone was rejected on{' '} - {/* TODO: add property for payout rejection date on milestones */} - {moment().format('MMM Do, YYYY')}.{isTrustee ? ' You ' : ' The team '}{' '} - can request another review for payout at any time. - - } - style={alertStyle} - /> - ); - break; - } - - const statuses = ( -
- {!milestone.immediatePayout && ( -
- Estimate: {estimatedDate} -
- )} -
- Reward: {reward} -
-
- ); - - const content = ( -
-
-
-

{milestone.title}

- {statuses} - {notification} - {milestone.content} -
-
-
- ); - return { key: i, stepProps, content }; + return { key: i, stepProps }; }); const stepSize = milestoneCount > 5 ? 'small' : 'default'; + const activeMilestone = proposal.milestones[this.state.step]; + const activeIsCurrent = activeMilestone.id === proposal.currentMilestone!.id; return (
{ className={classnames({ ['ProposalMilestones']: true, ['do-titles-overflow']: this.state.doTitlesOverflow, - [`is-count-${milestoneCount}`]: true, })} > {!!milestoneSteps.length ? ( @@ -208,7 +225,22 @@ class ProposalMilestones extends React.Component { ))} - {milestoneSteps[this.state.step].content} + ) : ( { subtitle="The creator of this proposal has not setup any milestones" /> )} + {rejectModal}
); } - private getActiveMilestoneIdx = () => { - const { milestones } = this.props.proposal; - const activeMilestone = - milestones.find( - m => - m.state === WAITING || - m.state === ACTIVE || - (m.state === PAID && !m.isPaid) || - m.state === REJECTED, - ) || milestones[0]; - return milestones.indexOf(activeMilestone); + private handleShowRejectPayout = (milestoneId: number) => { + this.setState({ showRejectModal: true, rejectMilestoneId: milestoneId }); + // try to focus on text-area after modal loads + setTimeout(() => { + if (this.rejectInput) this.rejectInput.focus(); + }, 200); + }; + + private handleReject = () => { + const { proposalId } = this.props.proposal; + const { rejectMilestoneId, rejectReason } = this.state; + + this.props.rejectPayout(proposalId, rejectMilestoneId, rejectReason); + + this.setState({ showRejectModal: false, rejectMilestoneId: -1, rejectReason: '' }); }; private updateDoTitlesOverflow = () => { @@ -268,11 +305,281 @@ class ProposalMilestones extends React.Component { }; } -const ConnectedProposalMilestones = connect((state: AppState) => { - console.warn('TODO - new redux accounts/user-role-for-proposal', state); - return { - accounts: [], - }; -})(ProposalMilestones); +// Milestone +type MSProps = ProposalMilestone & DispatchProps; +interface MilestoneProps extends MSProps { + showRejectPayout: (milestoneId: number) => void; + isTeamMember: boolean; + isArbiter: boolean; + hasArbiter: boolean; + isCurrent: boolean; + proposalId: number; + isFunded: boolean; +} +const Milestone: React.SFC = p => { + const estimatedDate = moment(p.dateEstimated * 1000).format('MMMM YYYY'); + const reward = ; + const getAlertProps = { + [MILESTONE_STAGE.IDLE]: () => null, + [MILESTONE_STAGE.REQUESTED]: () => ({ + type: 'info', + message: ( + <> + The team requested a payout for this milestone {fmtDateFromNow(p.dateRequested)} + . It is currently under review. + + ), + }), + [MILESTONE_STAGE.REJECTED]: () => ({ + type: 'warning', + message: ( + + Payout for this milestone was rejected {fmtDateFromNow(p.dateRejected)}. + {p.isTeamMember ? ' You ' : ' The team '} can request another review for payout + at any time. + + ), + }), + [MILESTONE_STAGE.ACCEPTED]: () => ({ + type: 'info', + message: ( + + Payout for this milestone was accepted {fmtDateFromNow(p.dateAccepted)}.{' '} + {reward} will be sent to{' '} + {p.isTeamMember ? ' you ' : ' the team '} soon. + + ), + }), + [MILESTONE_STAGE.PAID]: () => ({ + type: 'success', + message: ( + + The team was awarded {reward}{' '} + {p.immediatePayout && ` as an initial payout `} on {fmtDate(p.datePaid)}. + + ), + }), + } as { [key in MILESTONE_STAGE]: () => AlertProps | null }; + + const alertProps = getAlertProps[p.stage](); + + return ( +
+
+
+

{p.title}

+
+ {!p.immediatePayout && ( +
+ Estimate: {estimatedDate} +
+ )} +
+ Reward: {reward} +
+
+ {alertProps && ( + + )} + {p.content} +
+ +
+
+ ); +}; + +const MilestoneAction: React.SFC = p => { + if (!p.isCurrent || !p.isFunded || p.stage === MILESTONE_STAGE.PAID) { + return null; + } + if (!p.hasArbiter && !p.isTeamMember) { + return null; + } + + // TEAM INFO + const team = { + [MILESTONE_STAGE.IDLE]: () => ( + <> +

Payment Request

+ {p.immediatePayout && ( +

+ Congratulations on getting funded! You can now begin the process of receiving + your initial payment. Click below to request the first milestone payout. It + will instantly be approved, and you’ll receive your funds shortly thereafter. +

+ )} + {!p.immediatePayout && + p.index === 0 && ( +

+ Congratulations on getting funded! Click below to request your first + milestone payout. +

+ )} + {!p.immediatePayout && + p.index > 0 &&

You can request a payment for this milestone.

} + + + ), + [MILESTONE_STAGE.REQUESTED]: () => ( + <> +

Payment Requested

+

+ The milestone payout was requested on {fmtDate(p.dateRequested)}. You will be + notified when it has been reviewed. +

+ + ), + [MILESTONE_STAGE.REJECTED]: () => ( + <> +

Payment Rejected

+

The request for payout was rejected for the following reason:

+ {p.rejectReason} +

You may request payout again when you are ready.

+ + + ), + [MILESTONE_STAGE.ACCEPTED]: () => ( + <> +

Awaiting Payment

+

+ Payout approved on {fmtDate(p.dateAccepted)}! You will receive payment shortly. +

+ + ), + [MILESTONE_STAGE.PAID]: () => <>, + } as { [key in MILESTONE_STAGE]: () => ReactNode }; + + // OUTSIDERS/OTHERS INFO + const others = { + [MILESTONE_STAGE.IDLE]: () => ( + <> +

Payment Request

+

The team may request a payout for this milestone at any time.

+ + ), + [MILESTONE_STAGE.REQUESTED]: () => ( + <> +

Payment Requested

+

+ The team requested a payout on {fmtDate(p.dateRequested)}, and awaits approval. +

+ + ), + [MILESTONE_STAGE.REJECTED]: () => ( + <> +

Payment Rejected

+

+ The payout request was denied on {fmtDate(p.dateRejected)} for the following + reason: +

+ {p.rejectReason} + + ), + [MILESTONE_STAGE.ACCEPTED]: () => ( + <> +

Awaiting Payment

+

The payout request was approved on {fmtDate(p.dateAccepted)}.

+ + ), + [MILESTONE_STAGE.PAID]: () => <>, + } as { [key in MILESTONE_STAGE]: () => ReactNode }; + + // ARBITER INFO + const arbiter = { + [MILESTONE_STAGE.IDLE]: () => ( + <> +

Payment Request

+

+ The team may request a payout for this milestone at any time. As arbiter you + will be responsible for reviewing these requests. +

+ + ), + [MILESTONE_STAGE.REQUESTED]: () => ( + <> +

Payment Requested

+

+ The team requested a payout on {fmtDate(p.dateRequested)}, and awaits your + approval. +

+
+ + +
+ + ), + [MILESTONE_STAGE.REJECTED]: () => ( + <> +

Payment Rejected

+

+ You rejected this payment request on {fmtDate(p.dateRejected)} for the following + reason: +

+ {p.rejectReason} + + ), + [MILESTONE_STAGE.ACCEPTED]: () => ( + <> +

Awaiting Payment

+

You approved this payment request on {fmtDate(p.dateAccepted)}.

+ + ), + [MILESTONE_STAGE.PAID]: () => <>, + } as { [key in MILESTONE_STAGE]: () => ReactNode }; + + let content = null; + if (p.isTeamMember) { + content = team[p.stage](); + } else if (p.isArbiter) { + content = arbiter[p.stage](); + } else { + content = others[p.stage](); + } + + // special warning if no arbiter is set for team members + if (!p.hasArbiter && p.isTeamMember) { + content = ( + + We are sorry for the inconvenience, but in order to have milestone payouts + reviewed an arbiter must be assigned. Please{' '} + + contact support + {' '} + for help. +

+ } + /> + ); + } + + return ( + <> +
+
{content}
+ + ); +}; + +const ConnectedProposalMilestones = connect<{}, DispatchProps, OwnProps, AppState>( + undefined, + { + requestPayout: proposalActions.requestPayout, + acceptPayout: proposalActions.acceptPayout, + rejectPayout: proposalActions.rejectPayout, + }, +)(ProposalMilestones); export default ConnectedProposalMilestones; diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 3389d7b0..afc06c7b 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -1,10 +1,19 @@ -import { ProposalDraft, CreateMilestone, STATUS, PROPOSAL_ARBITER_STATUS } from 'types'; +import { + ProposalDraft, + CreateMilestone, + STATUS, + MILESTONE_STAGE, + PROPOSAL_ARBITER_STATUS, +} from 'types'; import { User } from 'types'; import { getAmountError, isValidAddress } from 'utils/validators'; -import { MILESTONE_STATE, Proposal } from 'types'; import { Zat, toZat } from 'utils/units'; import { ONE_DAY } from 'utils/time'; -import { PROPOSAL_CATEGORY } from 'api/constants'; +import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants'; +import { + ProposalDetail, + PROPOSAL_DETAIL_INITIAL_STATE, +} from 'modules/proposals/reducers'; export const TARGET_ZEC_LIMIT = 1000; @@ -170,7 +179,7 @@ export function proposalToContractData(form: ProposalDraft): any { } // This is kind of a disgusting function, sorry. -export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal { +export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDetail { const { invites, ...rest } = draft; const target = parseFloat(draft.target); @@ -189,22 +198,23 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal { funded: Zat('0'), contributionMatching: 0, percentFunded: 0, - stage: 'preview', + stage: PROPOSAL_STAGE.PREVIEW, category: draft.category || PROPOSAL_CATEGORY.CORE_DEV, isStaked: true, arbiter: { status: PROPOSAL_ARBITER_STATUS.ACCEPTED, }, milestones: draft.milestones.map((m, idx) => ({ + id: idx, index: idx, title: m.title, content: m.content, amount: toZat(target * (parseInt(m.payoutPercent, 10) / 100)), dateEstimated: m.dateEstimated, immediatePayout: m.immediatePayout, - isPaid: false, payoutPercent: m.payoutPercent.toString(), - state: MILESTONE_STATE.WAITING, + stage: MILESTONE_STAGE.IDLE, })), + ...PROPOSAL_DETAIL_INITIAL_STATE, }; } diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index dcb91d5e..02e083c5 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -6,6 +6,9 @@ import { getProposalUpdates, getProposalContributions, postProposalComment as apiPostProposalComment, + requestProposalPayout, + acceptProposalPayout, + rejectProposalPayout, } from 'api/api'; import { Dispatch } from 'redux'; import { Proposal, Comment, ProposalPageParams } from 'types'; @@ -14,6 +17,52 @@ import { getProposalPageSettings } from './selectors'; type GetState = () => AppState; +function addProposalUserRoles(p: Proposal, state: AppState) { + if (state.auth.user) { + const authUserId = state.auth.user.userid; + if (p.arbiter.user) { + p.isArbiter = p.arbiter.user.userid === authUserId; + } + if (p.team.find(t => t.userid === authUserId)) { + p.isTeamMember = true; + } + } + return p; +} + +export function requestPayout(proposalId: number, milestoneId: number) { + return async (dispatch: Dispatch) => { + return dispatch({ + type: types.PROPOSAL_PAYOUT_REQUEST, + payload: async () => { + return (await requestProposalPayout(proposalId, milestoneId)).data; + }, + }); + }; +} + +export function acceptPayout(proposalId: number, milestoneId: number) { + return async (dispatch: Dispatch) => { + return dispatch({ + type: types.PROPOSAL_PAYOUT_ACCEPT, + payload: async () => { + return (await acceptProposalPayout(proposalId, milestoneId)).data; + }, + }); + }; +} + +export function rejectPayout(proposalId: number, milestoneId: number, reason: string) { + return async (dispatch: Dispatch) => { + return dispatch({ + type: types.PROPOSAL_PAYOUT_REJECT, + payload: async () => { + return (await rejectProposalPayout(proposalId, milestoneId, reason)).data; + }, + }); + }; +} + // change page, sort, filter, search export function setProposalPage(pageParams: Partial) { return async (dispatch: Dispatch, getState: GetState) => { @@ -49,7 +98,7 @@ export function fetchProposals() { export type TFetchProposal = typeof fetchProposal; export function fetchProposal(proposalId: Proposal['proposalId']) { - return async (dispatch: Dispatch) => { + return async (dispatch: Dispatch, getState: GetState) => { dispatch({ type: types.PROPOSAL_DATA_PENDING, payload: { proposalId }, @@ -58,7 +107,7 @@ export function fetchProposal(proposalId: Proposal['proposalId']) { const proposal = (await getProposal(proposalId)).data; return dispatch({ type: types.PROPOSAL_DATA_FULFILLED, - payload: proposal, + payload: addProposalUserRoles(proposal, getState()), }); } catch (error) { dispatch({ diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts index 36e4074a..df14a1fd 100644 --- a/frontend/client/modules/proposals/reducers.ts +++ b/frontend/client/modules/proposals/reducers.ts @@ -10,10 +10,19 @@ import { } from 'types'; import { PROPOSAL_SORT } from 'api/constants'; +export interface ProposalDetail extends Proposal { + isRequestingPayout: boolean; + requestPayoutError: string; + isRejectingPayout: boolean; + rejectPayoutError: string; + isAcceptingPayout: boolean; + acceptPayoutError: string; +} + export interface ProposalState { page: LoadableProposalPage; - detail: null | Proposal; + detail: null | ProposalDetail; isFetchingDetail: boolean; detailError: null | string; @@ -36,6 +45,15 @@ export interface ProposalState { deleteContributionError: null | string; } +export const PROPOSAL_DETAIL_INITIAL_STATE = { + isRequestingPayout: false, + requestPayoutError: '', + isRejectingPayout: false, + rejectPayoutError: '', + isAcceptingPayout: false, + acceptPayoutError: '', +}; + export const INITIAL_STATE: ProposalState = { page: { page: 1, @@ -203,14 +221,14 @@ export default (state = INITIAL_STATE, action: any) => { // if requesting same proposal, leave the detail object state.detail && state.detail.proposalId === payload.proposalId ? state.detail - : loadedInPage || null, + : { ...loadedInPage, ...PROPOSAL_DETAIL_INITIAL_STATE } || null, isFetchingDetail: true, detailError: null, }; case types.PROPOSAL_DATA_FULFILLED: return { ...state, - detail: payload, + detail: { ...payload, ...PROPOSAL_DETAIL_INITIAL_STATE }, isFetchingDetail: false, }; case types.PROPOSAL_DATA_REJECTED: @@ -221,6 +239,78 @@ export default (state = INITIAL_STATE, action: any) => { detailError: (payload && payload.message) || payload.toString(), }; + case types.PROPOSAL_PAYOUT_REQUEST_PENDING: + return { + ...state, + detail: { + ...state.detail, + isRequestingPayout: true, + requestPayoutError: '', + }, + }; + case types.PROPOSAL_PAYOUT_REQUEST_FULFILLED: + return { + ...state, + detail: { ...payload, ...PROPOSAL_DETAIL_INITIAL_STATE }, + }; + case types.PROPOSAL_PAYOUT_REQUEST_REJECTED: + return { + ...state, + detail: { + ...state.detail, + isRequestingPayout: false, + requestPayoutError: (payload && payload.message) || payload.toString(), + }, + }; + + case types.PROPOSAL_PAYOUT_REJECT_PENDING: + return { + ...state, + detail: { + ...state.detail, + isRejectingPayout: true, + rejectPayoutError: '', + }, + }; + case types.PROPOSAL_PAYOUT_REJECT_FULFILLED: + return { + ...state, + detail: { ...payload, ...PROPOSAL_DETAIL_INITIAL_STATE }, + }; + case types.PROPOSAL_PAYOUT_REJECT_REJECTED: + return { + ...state, + detail: { + ...state.detail, + isRejectingPayout: false, + rejectPayoutError: (payload && payload.message) || payload.toString(), + }, + }; + + case types.PROPOSAL_PAYOUT_ACCEPT_PENDING: + return { + ...state, + detail: { + ...state.detail, + isAcceptingPayout: true, + acceptPayoutError: '', + }, + }; + case types.PROPOSAL_PAYOUT_ACCEPT_FULFILLED: + return { + ...state, + detail: { ...payload, ...PROPOSAL_DETAIL_INITIAL_STATE }, + }; + case types.PROPOSAL_PAYOUT_ACCEPT_REJECTED: + return { + ...state, + detail: { + ...state.detail, + isAcceptingPayout: false, + acceptPayoutError: (payload && payload.message) || payload.toString(), + }, + }; + case types.PROPOSAL_COMMENTS_PENDING: return { ...state, diff --git a/frontend/client/modules/proposals/types.ts b/frontend/client/modules/proposals/types.ts index 7be80b5a..0f06fe03 100644 --- a/frontend/client/modules/proposals/types.ts +++ b/frontend/client/modules/proposals/types.ts @@ -32,6 +32,21 @@ enum proposalTypes { POST_PROPOSAL_CONTRIBUTION = 'POST_PROPOSAL_CONTRIBUTION', SET_PROPOSAL_PAGE = 'SET_PROPOSAL_PAGE', + + PROPOSAL_PAYOUT_REQUEST = 'PROPOSAL_PAYOUT_REQUEST', + PROPOSAL_PAYOUT_REQUEST_FULFILLED = 'PROPOSAL_PAYOUT_REQUEST_FULFILLED', + PROPOSAL_PAYOUT_REQUEST_REJECTED = 'PROPOSAL_PAYOUT_REQUEST_REJECTED', + PROPOSAL_PAYOUT_REQUEST_PENDING = 'PROPOSAL_PAYOUT_REQUEST_PENDING', + + PROPOSAL_PAYOUT_REJECT = 'PROPOSAL_PAYOUT_REJECT', + PROPOSAL_PAYOUT_REJECT_FULFILLED = 'PROPOSAL_PAYOUT_REJECT_FULFILLED', + PROPOSAL_PAYOUT_REJECT_REJECTED = 'PROPOSAL_PAYOUT_REJECT_REJECTED', + PROPOSAL_PAYOUT_REJECT_PENDING = 'PROPOSAL_PAYOUT_REJECT_PENDING', + + PROPOSAL_PAYOUT_ACCEPT = 'PROPOSAL_PAYOUT_ACCEPT', + PROPOSAL_PAYOUT_ACCEPT_FULFILLED = 'PROPOSAL_PAYOUT_ACCEPT_FULFILLED', + PROPOSAL_PAYOUT_ACCEPT_REJECTED = 'PROPOSAL_PAYOUT_ACCEPT_REJECTED', + PROPOSAL_PAYOUT_ACCEPT_PENDING = 'PROPOSAL_PAYOUT_ACCEPT_PENDING', } export default proposalTypes; diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index 2a8686d2..4eb6ab21 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -6,7 +6,6 @@ import { PageParams, UserProposal, RFP, - MILESTONE_STATE, ProposalPage, } from 'types'; import { UserState } from 'modules/users/reducers'; @@ -91,16 +90,12 @@ export function formatProposalFromGet(p: any): Proposal { ? 0 : proposal.funded.div(proposal.target.divn(100)).toNumber(); if (proposal.milestones) { - proposal.milestones = proposal.milestones.map((m: any, index: number) => { - return { - ...m, - index, - amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100), - // TODO: Get data from backend - state: MILESTONE_STATE.WAITING, - isPaid: false, - }; + const msToFe = (m: any) => ({ + ...m, + amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100), }); + proposal.milestones = proposal.milestones.map(msToFe); + proposal.currentMilestone = msToFe(proposal.currentMilestone); } return proposal; } diff --git a/frontend/stories/ProposalMilestones.story.tsx b/frontend/stories/ProposalMilestones.story.tsx index f9c25ef7..96be4161 100644 --- a/frontend/stories/ProposalMilestones.story.tsx +++ b/frontend/stories/ProposalMilestones.story.tsx @@ -5,25 +5,25 @@ import { Provider } from 'react-redux'; import { configureStore } from 'store/configure'; import { combineInitialState } from 'store/reducers'; import Milestones from 'components/Proposal/Milestones'; -import { MILESTONE_STATE } from 'types'; -const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE; +import { MILESTONE_STAGE } from 'types'; +const { IDLE, ACCEPTED, PAID, REJECTED } = MILESTONE_STAGE; import 'styles/style.less'; import 'components/Proposal/style.less'; import 'components/Proposal/Governance/style.less'; import { generateProposal } from './props'; -const msWaiting = { state: WAITING, isPaid: false }; -const msPaid = { state: PAID, isPaid: true }; -const msActive = { state: ACTIVE, isPaid: false }; -const msRejected = { state: REJECTED, isPaid: false }; +const msWaiting = { stage: IDLE }; +const msPaid = { stage: PAID }; +const msActive = { stage: ACCEPTED }; +const msRejected = { stage: REJECTED }; const trustee = 'z123'; const contributor = 'z456'; -const geometryCases = [...Array(10).keys()].map(i => - generateProposal({ milestoneCount: i + 1 }), -); +// const geometryCases = [...Array(10).keys()].map(i => +// generateProposal({ milestoneCount: i + 1 }), +// ); const cases: { [index: string]: any } = { // trustee - first @@ -38,11 +38,7 @@ const cases: { [index: string]: any } = { ['first - not paid']: generateProposal({ amount: 5, funded: 5, - milestoneOverrides: [ - { state: PAID, isPaid: false }, - msWaiting, - msWaiting, - ], + milestoneOverrides: [{ stage: PAID }, msWaiting, msWaiting], }), // trustee - second @@ -59,20 +55,12 @@ const cases: { [index: string]: any } = { ['second - not paid']: generateProposal({ amount: 5, funded: 5, - milestoneOverrides: [ - msPaid, - { state: PAID, isPaid: false }, - msWaiting, - ], + milestoneOverrides: [msPaid, { stage: PAID }, msWaiting], }), ['second - no vote']: generateProposal({ amount: 5, funded: 5, - milestoneOverrides: [ - msPaid, - { state: ACTIVE, isPaid: false }, - msWaiting, - ], + milestoneOverrides: [msPaid, { stage: ACCEPTED }, msWaiting], contributorOverrides: [{ milestoneNoVotes: [false, true, false] }], }), ['second - rejected']: generateProposal({ @@ -95,20 +83,12 @@ const cases: { [index: string]: any } = { ['final - not paid']: generateProposal({ amount: 5, funded: 5, - milestoneOverrides: [ - msPaid, - msPaid, - { state: PAID, isPaid: false }, - ], + milestoneOverrides: [msPaid, msPaid, { stage: PAID }], }), ['final - no vote']: generateProposal({ amount: 5, funded: 5, - milestoneOverrides: [ - msPaid, - msPaid, - { state: ACTIVE, isPaid: false }, - ], + milestoneOverrides: [msPaid, msPaid, { stage: ACCEPTED }], contributorOverrides: [{ milestoneNoVotes: [false, true, false] }], }), ['final - rejected']: generateProposal({ @@ -169,14 +149,14 @@ for (const key of Object.keys(cases)) { )); } -const geometryStories = storiesOf('Proposal/Milestones/geometry', module); +// const geometryStories = storiesOf('Proposal/Milestones/geometry', module); -geometryCases.forEach((gc, idx) => - geometryStories.add(`${idx + 1} steps`, () => ( -
- - - -
- )), -); +// geometryCases.forEach((gc, idx) => +// geometryStories.add(`${idx + 1} steps`, () => ( +//
+// +// +// +//
+// )), +// ); diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 63ae1635..2842b1f1 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -1,13 +1,12 @@ import { Contributor, - Milestone, - MILESTONE_STATE, + MILESTONE_STAGE, Proposal, ProposalMilestone, STATUS, PROPOSAL_ARBITER_STATUS, } from 'types'; -import { PROPOSAL_CATEGORY } from 'api/constants'; +import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants'; import BN from 'bn.js'; import moment from 'moment'; @@ -41,7 +40,7 @@ export function generateProposal({ funded?: number; created?: number; deadline?: number; - milestoneOverrides?: Array>; + milestoneOverrides?: Array>; contributorOverrides?: Array>; milestoneCount?: number; }) { @@ -110,15 +109,15 @@ export function generateProposal({ } const defaults: ProposalMilestone = { + id: 0, title: 'Milestone A', content: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`, dateEstimated: moment().unix(), immediatePayout: true, index: 0, - state: MILESTONE_STATE.WAITING, + stage: MILESTONE_STAGE.IDLE, amount: amountBn, - isPaid: false, payoutPercent: '33', }; return { ...defaults, ...overrides }; @@ -126,6 +125,7 @@ export function generateProposal({ const milestones = [...Array(milestoneCount).keys()].map(i => { const overrides = { + id: i, index: i, title: genMilestoneTitle(), immediatePayout: i === 0, @@ -158,7 +158,7 @@ export function generateProposal({ title: 'Crowdfund Title', brief: 'A cool test crowdfund', content: 'body', - stage: 'FUNDING_REQUIRED', + stage: PROPOSAL_STAGE.WIP, category: PROPOSAL_CATEGORY.COMMUNITY, isStaked: true, arbiter: { diff --git a/frontend/types/milestone.ts b/frontend/types/milestone.ts index 46661931..2809503f 100644 --- a/frontend/types/milestone.ts +++ b/frontend/types/milestone.ts @@ -7,16 +7,31 @@ export enum MILESTONE_STATE { PAID = 'PAID', } +// NOTE: sync with /backend/grand/utils/enums.py MilestoneStage +export enum MILESTONE_STAGE { + IDLE = 'IDLE', + REQUESTED = 'REQUESTED', + REJECTED = 'REJECTED', + ACCEPTED = 'ACCEPTED', + PAID = 'PAID', +} + export interface Milestone { index: number; - state: MILESTONE_STATE; + stage: MILESTONE_STAGE; amount: Zat; - isPaid: boolean; immediatePayout: boolean; dateEstimated: number; + dateRequested?: number; + dateRejected?: number; + dateAccepted?: number; + datePaid?: number; + rejectReason?: string; + paidTxId?: string; } export interface ProposalMilestone extends Milestone { + id: number; content: string; payoutPercent: string; title: string; diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index 64b1c66c..143d9033 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -1,5 +1,5 @@ import { Zat } from 'utils/units'; -import { PROPOSAL_CATEGORY } from 'api/constants'; +import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants'; import { CreateMilestone, Update, User, Comment, ContributionWithUser } from 'types'; import { ProposalMilestone } from './milestone'; import { RFP } from './rfp'; @@ -36,7 +36,7 @@ export interface ProposalDraft { brief: string; category: PROPOSAL_CATEGORY; content: string; - stage: string; + stage: PROPOSAL_STAGE; target: string; payoutAddress: string; deadlineDuration: number; @@ -56,9 +56,12 @@ export interface Proposal extends Omit { percentFunded: number; contributionMatching: number; milestones: ProposalMilestone[]; + currentMilestone?: ProposalMilestone; datePublished: number | null; dateApproved: number | null; arbiter: ProposalProposalArbiter; + isTeamMember?: boolean; // FE derived + isArbiter?: boolean; // FE derived } export interface TeamInviteWithProposal extends TeamInvite {