From d533ed2a85bd035ec9167d5ae085c8db13439930 Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Fri, 29 Nov 2024 14:58:07 +0800 Subject: [PATCH 1/4] chore: include latest features in whats new page (#7956) * feat: update whatsnew with new MRF features * feat: update copy for whatsnew * feat: update copy --- frontend/src/constants/links.ts | 2 + .../features/whats-new/FeatureUpdateList.ts | 39 +++++++++++++++++- .../features/whats-new/WhatsNewContent.tsx | 8 ++-- .../whats-new/assets/10-mrf-approvals.gif | Bin 0 -> 78500 bytes .../assets/9-mrf-email-notifications.gif | Bin 0 -> 208211 bytes 5 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 frontend/src/features/whats-new/assets/10-mrf-approvals.gif create mode 100644 frontend/src/features/whats-new/assets/9-mrf-email-notifications.gif diff --git a/frontend/src/constants/links.ts b/frontend/src/constants/links.ts index caf980acdb..d2e52c3e13 100644 --- a/frontend/src/constants/links.ts +++ b/frontend/src/constants/links.ts @@ -16,6 +16,8 @@ export const GUIDE_FORM_MRF = 'https://go.gov.sg/formsg-guide-mrf' export const GUIDE_MRF_MODE = 'http://go.gov.sg/formsg-mrf' export const GUIDE_MYINFO_BUILDER_FIELD = 'https://go.gov.sg/formsg-guide-singpass-myinfo' +export const GUIDE_SINGPASS_FEATURES = + 'https://go.gov.sg/formsg-guide-singpass-myinfo' export const GUIDE_SPCP_ESRVCID = 'https://go.gov.sg/formsg-guide-singpass-myinfo' export const GUIDE_ENABLE_SPCP = diff --git a/frontend/src/features/whats-new/FeatureUpdateList.ts b/frontend/src/features/whats-new/FeatureUpdateList.ts index 0ca615e1ad..9dd4f8b42c 100644 --- a/frontend/src/features/whats-new/FeatureUpdateList.ts +++ b/frontend/src/features/whats-new/FeatureUpdateList.ts @@ -4,6 +4,7 @@ import { RequireExactlyOne } from 'type-fest' import { GUIDE_MRF_MODE, GUIDE_PAYMENTS_ENTRY, + GUIDE_SINGPASS_FEATURES, GUIDE_SPCP_ESRVCID, } from '~constants/links' @@ -13,6 +14,8 @@ import Animation4 from './assets/4-dnd.json' import MyInfoStorageMode from './assets/6-myinfo-storage.svg' import ChartsSvg from './assets/7-charts_announcement.svg' import MrfAnimation from './assets/8-mrf_announcement.json' +import MrfEmailNotifications from './assets/9-mrf-email-notifications.gif' +import MrfApprovals from './assets/10-mrf-approvals.gif' import foldersDashboard from './assets/folders_dashboard.svg' // image can either be a static image (using url) or an animation (using animationData) @@ -39,8 +42,42 @@ export interface FeatureUpdateList { // New features should be added at the top of the list. export const FEATURE_UPDATE_LIST: FeatureUpdateList = { // Update version whenever a new feature is added. - version: 6, + version: 7, features: [ + { + title: 'Use Multi-respondent forms for approval workflows', + date: new Date('20 Nov 2024 GMT+8'), + description: dedent`You can now enable approvals from Step 2 onwards using Yes/No fields. This allows you to determine whether an existing workflow should continue based on the approval of the current step. + + + For every step that enables this feature, a Yes/No field is required. If Yes is selected, the form continues to the next step. If No is selected, the workflow ends at that step.`, + image: { + url: MrfApprovals, + alt: 'Use Multi-respondent forms for approval workflows', + }, + }, + { + title: + 'Email notifications and summary of responses for Multi-respondent forms', + date: new Date('20 Nov 2024 GMT+8'), + description: dedent` + You can now send the email summary of responses and approval outcomes (approved/rejected/completed) to both form respondents and other parties.`, + image: { + url: MrfEmailNotifications, + alt: 'Email notifications and summary of responses for Multi-respondent forms', + }, + }, + { + title: 'Enhanced Singpass features for Storage mode forms', + date: new Date('26 Aug 2024 GMT+8'), + description: dedent` + * Limit each NRIC/FIN/UEN to one submission per form + * Whitelist respondents by NRIC + * Opt out of collecting NRIC of respondents + + + Find out more about these optional features [here](${GUIDE_SINGPASS_FEATURES})`, + }, { title: 'Introducing Multi-respondent forms!', date: new Date('04 Apr 2024 GMT+8'), diff --git a/frontend/src/features/whats-new/WhatsNewContent.tsx b/frontend/src/features/whats-new/WhatsNewContent.tsx index 99c95cbd64..76a03ecab5 100644 --- a/frontend/src/features/whats-new/WhatsNewContent.tsx +++ b/frontend/src/features/whats-new/WhatsNewContent.tsx @@ -65,9 +65,11 @@ export const WhatsNewContent = ({ {title} {description} - - {renderedImage} - + {renderedImage ? ( + + {renderedImage} + + ) : null} ) } diff --git a/frontend/src/features/whats-new/assets/10-mrf-approvals.gif b/frontend/src/features/whats-new/assets/10-mrf-approvals.gif new file mode 100644 index 0000000000000000000000000000000000000000..5969da222ccc0ea31ca3873c39180cffe97db703 GIT binary patch literal 78500 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�Oi6EE4)t7_7qzN)~5h7gAq3S*+z46nypfwIcCa^osln zxw4NT^%lvzdZ$q_r_>>TOr^Dm?mj~{{^sq`E=u?5m+urWB-J)krJ4kLFRAE6^1H_; zsRxD&$n$++>+H@ZQyD=Le0S~};WhR_%Q@+;s#rspUjQ(xt)GF{fKcuo?FX- z=?!a@Dp64eDg7w;BO{IQ!+P=CC`Gw!+S<) z3=VkAAh%N`O^$`{U(VRSPZ-}s7t$t11*%<>j{NYx=es*Cy)MNRC#{i!LE&ep%^%R4 zTuITyYe;)eOV@Z2Ji=Qf%KwUZVlYk7;-riw4#Dck8L=}1Xm3}`cIkkmn z5yc^Y9CkI|ryq8`Fu(VIWQ)KKbNw(U6luU;LGjN34)Ec~505li^pl~LI}^W)v#L3DC>zEYw> z#G3e^vvFFu*}@2_TKtEmVrnSW*tKU#b^Vf(ATzT1p@2pNawZci?H*t~$GU$IONjvs zyW4N49=$%SOu*kyM6?f=g(F*B@l+nvLx>n_w@uijI%sc|5kcoL39^aEmG01=t`j$- zQAbOUG((P6>Xd-#2D3X$F+_C!SnM8R25CdFw6gjlkLW*ayi zED4nqBzdqiX1 z=eK`9f1Pt)Uc8R(_ceU3>-u~t$6=s?Mh$roN(!1kxYwb3!!iImOYHxEYBZ5(?7$V4 z2hhVw7Xy$w(Qcv_gsdv<9nol_Pz6-zH32_p2bSnH!6Yth5IYbfkWUXDJP3@zJf6Hq zvq)KoU%!qX5rF`q0q6Sozxn^qFABd|YU+P&W+!R;0+ol4Ak?CMZ2N-rEr?MlcT%K- zNSN6XgR}yaM&(hDMN~emy2|7QDvfwpEnOanVg|5^er}cQ{8Y3PbXr$TpX6P@bNzHq z4ZWf8!Xt>NBaPXiLjF$T;3$Zw5w=Sev=td*`csLBn!dV;J~fa*Y_thu1QmqAOe;Nn z?6q0Jqb!QCz4uf4`9Ma4X$Mt%mJ3d|E6_p}!~}RDLh{_Oi$Y{xox;y)EPZZn~WM;swO4eeyP@-ZWvJtokID z!)1%HkyEp1henCF8q%?I|7Ak0mxegKG>o#2*n8P-cp2ozQ;Uu}Tv6P3IZ%#Re>|M1T`_r6W!B-q*2Q$j+5l&MhrmmG zM`rlrf-A>w1t=1e7uW}RMbl{e-!TnE!%Wk~K63)%1!8$ZxQNf8+ZpQWhmElEmLHZa zpc8||CXInSJ1bIVil0YQ@Nws$rjU0(^41I9K9N%1@LA*ggIOv_14v*GpDFOew~J34 zjN#*kkcqDuB;ex)U_b@mG{6IykYYd=)c{_-3IIR|=7#vPp@0Gml%D9|KpmjMo;uJ) z--oXsPgwvYAPvlb5SYZPdccV~MoUTpco=2?LLdzS5e0xBv{L9ffElELYLEt2fw~kn zkdNqqaPWOU55NQL|K9-n52(XCTukz{xc|gE{MFt^e|U!{?Ui_kM@OjLc_Hx*e=|l& zPv+q7CeCZv3S`J}$~*kjh9k%9PM^O3)4Pzf1dSoJ=3mZc9-|RaF?aEb*p;~3HVG0R z4X@OQ83}SK%3{j&&egY{2;SIKI zcXm1?a6h3s&Fa`*J_bG-oGbE#zPIV^>bu!;ZZ@+h?w#A3Q0dB_PX@;=NjqU%Jpx;8rtOR3My=Z= z6wY;cT0D%Ma_8l#vJY$HjD-BEbOpvzBYw}F<|m;;WADm|O`_#aNL(x;gde9xo%n{uOkeH4g z6$?fT7j-lpUU7gQ!ZxY;wB#RpvgarYr7!G(J%ER|!|?gGwY9^v2`GX$lzqizl&3yE zN__Fi0w82hNIpJUyo?7K@bTj(5=YiXc%q=@!-tNk9fW@u#A0LgdP#m0sJF;Adw+Pf#g|rTK;#g8oR*q!2c>7GVX2~X)Crbp$Q3^I{W5FJ8>gE;=YNPa{-3c3N5q!hQ zMGvXT`t9&>o4Sx}REtmd?UmJ@l~gv@=1s`RWs~1|=#oy69|TwgFNjngc%+8!!(g*r zvO>O^pAbB`VX0N?)F%?xqYQd0BiwHEghH#16a|Q2y?1#{)L) z&(zdQK1U7r{Q*i4f~^xEflnM?3GhIcK-Gn<6M%t_31A@V0}vdX5eo;PTokRot9h{6lsne@aENepNK59BD2gRdDMs8r;GQh@}8 zA0obWXp<=5u(8t7K{ZAw?2v`P3{o7vR}_5sn!yGnLu8xA=1yd}EA?30{EQ8k2ffXp z2n`bu0wf?riU*P>NC2oH4fKKmL?c}Oo1Q6{ zMjK4}|3l9dc-_L7AbnH*T)3tUWCxJ^+~0d9(e;7D$)b`!dZuSD=!Lb?o~e=~nIxUj zC0!A_&@W#(m>`CXA*_EJK`mTA5YiO}F+#->rSP{1zOFxGU!cLZ4iR;;XgJ1_F=*WK z&{OD{Ow&&AB7zCyLi9{G9-a@8-&WB{^-L8))-ZE5s%P3TV%gqMk4hL2Z#%y?kymkX z33{d%K^rV-9%Toycyh}nkf7hs(Q~X?#y{`-|ibdwoQV|_fulib{f%>8W<-`zxq z#}5~i?|j&_3d6aNev92>txkjriQPvv6uc%93l!1?FTFVd2SV@MMd ztF1C?qgNOhSa{egs2lh4)d(1z(sedtdGk!AAXAyBDa~MbC3rL+J;_^PBOlV((H!vO z$XZ$=em4>Vhxlmlq2pVW>KH%~;T$0xKq8r(IB^1BKgdBpghmSN0Zvc^J~5rnE2P{8 zK`iFmNIL)+d?IB5Er9g!Q3l|ISqP*961 z%ZN7Xd0jV%MRV5F()FUg=M$E$C3y_AOUqyo3nu>+g#InRhvIM{@y{N%k-R$bAC16c zeQ*6UBO$!3{O7|qzaKtGE)S&(V3IUNc?(Lk3yEU?`@=R_G(Q@{sgrUR^Swcn<&r$H znjd8Zo@yxa9biUdcv#`piS2i$&AIEfVn)_cJxGV=j_E5~X_EZ08Yi%5(o7RG_#!^w zCG~@Z6o>gMKggR9(ZyHc?0f0F!_5+M_A0Au`M5lsCz~0du+@5a6g0zHeJM=4SnmN9}MiD#PK!Zg_)^@VRk_Y2?2Dq-P@;=x?wVizYTtjt_ltuP|-= z^ulw>`%ofYUKJNTZ^LCbl!)7JEVyA;>e=7@BK1Lzc5e;;`Ac$|eeUe^PxtCv+as!6 zVIQWUFyF#yW%~V{KjkKBio2HP%MTimAo))Sh_CT_SqOr7OM{#r^?;ffkw5)YXkjY0ay zBAi)d2aiD??B$fj;25)otk%=uFkCA-gTW1rCsav$Cz3m)tA(y=j(epg`kJFM2ssVz z0D?LHq6tX^GDEZIs-rtw&zfAz@srarCsjFC>V1Q0mOF^H3!ZkC%bga$Jz7!Sl}~33 z5qGv<8LpjSNy?kV^J&{@F&!6XMKuY90_Y<-0yrc}Tsr|%Lj^tt-Oq#z+G zim)Xe6aMT76PZ(|k)+g0w4_v&J$X`Yog*p0HX zuO<7QXL{cfEPW2U!CfL%{u&_CrKy|IDQq%M@!RkkIGIL-aK9L27+g$c4dxs&GfDB6gP&F%^!K}A@KeGmcPVe z8>wg!RMzk~J{G*pp|?|p3rg>def>-iAg2v{m~iB@}1}i4{Pvjyjr8U?kuH^0v}oJT2}LM zzQMQ^40SEfO!;Z7n=Ohbe6^Gr94k>)%c+(h)y;Qg9hS)c@#*P{7~=uard~=^LDh?- z4A`*71w;WDm;nYTZIQKsE>Hm*cx)G7qoM^@*W)QOP@s$26wClufLdHyIlCYh(hAZs z>RRcj-Jv)Zl%i0EghEx=RaLm3z=~(!k@J{|qZtK+XeCiJOP|TfDk?#Fjrv%+=m}i~ zg)-XBf}*Ft{PLx<3+fM|!AFpZf*6JJtB;>*YFlvPggx!X=B~6i)KL|D1pe)S{qO%k zDWU{3z~{ek^N73}PKt8BR8Xcd>kC6*(ZOWCIplDgq9MaqLoKY6VNz{NW3PEJI&mpmY7>QQBX{}a_!Ws0RL8@?C%X9|W1OnJVC;HK6SjO{2%q-K zZPw5*32G3s*s@nw6}WV4;yUhj&6D+9QkEe%&|;f2H$jlx*Zg_f{Z+ptuNDqm!@gR> zUE$-aZ4MwHT;4I{DbrrzR&x=$YAXZ#m^W zi)O=)Kbp#?jg?o);BdWMTfl? zc|A@j06knjcE?{q2@K)^G{8c>#_S#=d!UJmSekZm5QXa|=(vakU|M=w0s2u{V-SzB z+WT-4v?pjlHbt?x!+<;ljsY>GqF1lqq5lQD$hL?zz!$*-`4r&|vaEZ1O64|Nu& zkoP<4p+o)enfrVG1gh3n@KyM)d)~7o38S7LC7~P9$@l(9zK^a4WM%wbYm3+=rEH=+ zNKSFGL6LN+`+RgtYM|B-LQ7M+)P29Hx!Gn=KxeOHPI&Zff50G~)0|6^_>?j@nwYEei7D zaW@?HPd_<5uTkofJ z=g^5GCJF?Tqbg^mk$W}$!S01-M&FKZHZ&pFTH9JUZ>eL8;=r6h&7XZZot1+=dEpVb zYo7vBcjY?ytl?QHj3fm0?0cWv8+?O5@pyWwiUWkSQLaQLeuxWA8AR^OW z)kaLRW%NZNXIamQO-D@4W@IbLmvX&br#UiE7W{Ba7B96!g^-b7 z+;U2HeFH(iH@3G~cRZIj^t9lYl*@XW#(ZB6TQA-^d}DcyHA~4j)LWtF-nyTHrbVZ` zytH3O{fCm8pf!N%?;SL#J|y%>tEP;(!6f(uEg%zk13b`+V@D)t@Cl@m$AL8b^#BhV zTV!NZz&LqC`bO$RW&nrF+*dAGyb*S{3zu$&#Cz$=b>wb@0Kkkk**D;*U%(NB29)Lq z7Rc0@Svdg#0Uo$Gj!8jP09?T~YLS{zUc-7znwpWOm#^NwVoM~}>JUnB zPK9WKq>LmE;AgGC91-vX-1|E8p-mcD;n zzDb~_ipe)+17#ni{ju~NPWn8_kqW(+@`&w$H|>2L5}|kC>4M#g^N;&dJ8zs#Jj*>H$Gy?Mhy^`sHkX4zU8tL>dhd2=&g%A96}InA-Q@E*BrZ`t)yOIuP)+eAFf z@w#i`hwZ;4HC(+(i&2~vgQv8%x;?OB46f>WhBM@ZYYSh5ZF-Tgs2R&QM>a%k-t~sY zD*)A}j{n*6W#a@=N?wjP@OD`Pv;yOKYf{K zADQ4g!+nU-o_U7n_1J`oQ^j0hS zH4uafE#5M~s4ZIL+u4cjR7GbI=2+z}1K5K*qV;{Z%pir{46eW_lSWo*Slc?RmCX2tUq17<8)+=L^?Hglj&gb9wuPKC;ua&UZ%d>l0;gb+Dh_~-Qjouo}BO9abrUo}RQjzD8;n93UEJIL8k-VsbE*XUY z!UQztm|IE6$p9X3`URgwQI1X*ApkJ~@df2RdUO==(B06cW4Q+l45;9d;USz!jW#hk zLd%XO`|#nz7|_Cy8}7U47EmTY1xL4yw2ulN!%@^7=(*k3?u9Of*nw6a%`{2?XaHDL zKu-_F4UGpLu7Xa0pn%R>+H?Pli2gtMZHNmqs3)U+h@c=?%lr>5y8lX9J4%wZ)G^58 zm-Q`_vKHG;yrwQw-yQGmeUEGp=NP=_D(m>+%}Z(kR6lw5n+us(%8OZ9<3!mLOqdrJ zP7PlNPg=4vo{@|9T%Y-?&AiDGDh|PWu6dh=71&QV;JxLZ#8O__#`^u)0!zKi{F5VX z4R&}8Ww8kJnrGXQ?bFXsp3N9Qc+;E{a0Knp?LsV*;Sh3SI*YRCo@3g6>vr9(b<8;1 zca}l$-L*V->q0K?dGz}CcXQ%(VJULvy!ylL-VrN*4pAN(fNVeXR*>P~!oz#U2#YQ+ zn4&o7`6Z8tvkn*RZrwMlJ^H3zxoVS!^15ei(}{=+Mz;02JLmqQ;O5oWK5Nk1>-*eP z`rB>=Oj|$XqRc|2F4_U{hwp}vZ>Ca(I2oI&5 z3)fZgo9U%~KI}5r26ms5?IE0L?GSWCsSjZC<&+JHY$e$vM2Gu8A-^+P>8mJ14tq|m z>x|6vhw3TS%xD_If|Bb;_lNp2M_+WJF)cXVaaLAA@j_DZ z6Tc&|M)n<@F0Xa&Rmug%4>Pk7v@YTiIr@i)f^dRBf!5R`Uw_Xs}-Gf>l@As`q)ZUf?z9nT^>AiyBf*e$&` zX4<~Jd-sko+c13mYW*?G3{8L9av*KPp7<>XAFSOPfuL1X)`ai_iSS8Y1%l9#BS)a* z7gw}H=94PFpXq4#Jzo+)IdmQ=Kj&o=uw{fvAJ+Cj=hI zc~*PVmmW)tj?@n2`;1@#F-wTi>EFR;#0;#1!QWgxw^wG-ZAF+DxKs5HXseTaUl(g=9l*}U6u zmYNX9+N$PGL-dSQF4{QRCp4J`D5%aPXuM#?VV<=?R(M}2Th>?a2siKUu+f2j_GgS& z3+TF(>3`g??QJ|dlQ6eW+XiKzFL&qC`-%om1YLt`3zxu$Hd8OC?48UPIupwFuG$VC z=DXY?Qe}H5+k4kg5oO#;j(2CdWqsxCVeuum5K;O@&K8Pa*Bw~ryK5QtIwmp~(i}z) z7Fhaj_EQ+56BXW9|76SX!_%INNFkjZw*j`r+gFuFuIk?CDf&Qrx?7=p=PPsJ@@Hae zGgm@j*_G+Y9K+NUBy?9lyvevSXDqR)Qk84C=zjY9?H#gFPD8#OAN}#fgGV2KoBiFM3cD!xbn#+!krhU6cELwhG{ZZW29GR4yr8??i$Iv7F zjF~*={p$p@VAk#U9MN${m#mWYA4uK+_N0aeYhn_M$WoiV>)g?`^Ju(m<~TuvB{9sZ z+UM-XB`0fL)CqwvTP=u}LAM_Jc!=EMq|Tqrh6v&(Pc19Z28*;-&2?;l4ipqCeY{n-P(B zsP=>k8b=fEhKzZTA$eB*dPLOE0c&*4<~@-N_!9H-qXL$&NZZmX$xUiNO+GDfW*sTt z&_?(oY%j3{y|!otHH2~v#0P(yia>CHBImt z<}h%4aaQE!C5BcnlMZ_;Y3$HH`sriy>?LYRgg5;*gE-2aP<%q1tqms-nyUBMQq3^?Q3z>{Ic`^qY8By(gm_#82P zVYtVt6^69<3?)lbq1pgdLV4vuf)_77YOnHvm`mf!d!N#^69;irG%LefPH!2p!jiR! zXXVcvXzG;1U^j>vXFN{a6SGteGMUQbE1fc!`a90GUGnm8WYRQy30n;>R>-0O&aNXm zt}=cU7F}tdh)`0x=Lx{2+9DVSzfc%~;ZE;(e9v3G#efB*06>6WUHu%%8kri(0H8(> zaXUTdTx1D)3#z(r>xR^iG>!fMVF5ca=y_2^;DrF_2KQQ@Z$6Zf$h@7wVQ;6WgTD{bOT-xjdEg#e?Eu?$OwR78E8`xFQ^2hA31&&aqnM8`}^+% z<22g8KE(g-UMOKajC>%uJC7xEe$J8{zxqASDWVvUDJk_g8S#|b^9-r35g5=68_;Ux zHI=#z_mQRSgA=M16xd8cW~2|Te#s!{gX z^Hkih$e5&EbG-1|DLA2xjv?dT7Iuvn!3k}-_kahcu`Y?!Yu>}|C9hhVvWAK0c_%Jd zc7~`OH)`oh2blire*M`fwI_5$@K6Uc=Yg>eaV%n&&?2qBG5^SzpVX6{C3dDAwpDeV z-4Mys3z%EbcAv%XD)8~Dj9)R-UYWOet=w^0u8`O4t6{A1;-s-q#?F)fDPtwmx3R`s zeF$UTXVp-wIgdWXyhGQLoBY$oZGPqisXNh8*g*JkG8zI+ zZqvhb$#Nba8UtaaI>j?ocYz`iPv?wPzgfI5uqR$Tm!;vw6Kf2~6|J1>_paA3%vH0c zg*X45M=s4FJ-ju%!K2Sll{m73Ls$0Tlgvq;Jn!?XtJWP?bFvA|X4$(5{4WWgSNQFj z8EPQ=;1H;2Z{BFH> zma>AaxG5!J)l6ZALb6QC2veDXvclwX z78U*+g2zwd9v(E%c;gwX2VSehy^M*%)TKfOnQlo7658aS9R4`*&+cW@JaLe_5@vT zl!r$1@tG5@>Y7)KEzM+%P%FzA&S23xqnxcw=HVP(enaxe4yJNo2$egEXcXp1)PMv$VJ8RuG#YoHjQ$vE9eQF^#HjbDFNoUalZ70E zV1V!-#Tp|*H161X1J;NQU{OLgwWU-2q7x!PWAiKI=ePZzF<5v~)Bzc>q_iBt1AHU2 zpgzsXc?`a#RXMen`?3);!R}XY3yVrnp?7unqRIx%4<4kW^Z?KR6jB8Gd(`IEuD8+F zBjwiBH`p#sx_L7el{g{_@;P*OWN9RDDar@{2o?WJnkjaD2pa9L%qcGT`~Sf1895sB zHnEn!D(?43d7_z**m@W|Jx&;k``|DloVJ; z%Xma%u`x%SyrhR3{9PA`-V$6<3b|Z5_grsF{QAv!wGcPGw|m{MOmGbuXv@_6qUt@TMaD}3z_(G=GA zqXzGem~$;n4I};Hq|Mjvt6=Nqeg`Q!3vKY`s&A8I$IYW*>*jjp-JNn1M|)fD=#$mC zICG0IdCLKOwKoD<& zz)vW=mh>v`hHB&vN8UBwFzazP9@`>bziRe+PfNAnTL~4l=YJ^{X3Dnzpw;XSDf?h4sozAWm#^~NiW!%`W*Cw#p6%fPP7&M0*T zVIlOE8|q3j6_muo?B%L-qgTw^yEx2-VH0YSDMuO-LJgA3%UG_?=RONCQE$yrwTtHm z(9Or`GBlhGy|R_uvkhD3$cj934o*7ND>hWoal;Tm&Do^o)a<9*bLdl@#NP7i;r6X( zZBLu{s10`v(+&6d@ZE@mLZ@60gU*N zFr|=!Xg~@^Ks1_cFbHsvhruJHFVKo00A66^zjA9FA^sj2s&>4Q@kk^whRdHg~F zpe`vX1)E?E0R(X)^jsF|Wq^r%i<%ldB1|E${O7FrH~cRYykOTS08efHv)8x7{1p5N ztIK~j(1-nG6}St?JHM-L*ZjCN*?HJKcYY{U-L7ORu!iN3vlf;};63lxz<&C;p4!JL zYVczd%}j}>#YI?JFc?pJ-{WfVTb=3*f%xIduaq@-?=${Vy8PP>lGnGDxW+uT;a*M)L_1s zH_F9w?cs$$o9@e-eHq8~USThNe7YX4=2|n4YfbRfm|32>z4-~9&=+cysWS&@YncQQ znTZ=7ie}(dOl7p8dM7*&7G2nc#X42Olb`85+fv8oo%=(VxG4&x)~EPhpJYqIN#Eq< zZ%TRr_xI=L*K`@^tJ3aPX@ zmbSl0viMx_k%OL4v*S=OkB+Fm>CUD6xM!D^Cmhv_+44c0;c`Re8mX%~)gKfil_V6d+BnE^nO~ z0!U9?S3t64(X^VPZv_Y8JzQ4z=kbPm5u}aQyjl0UjIQ(jr`dY(FF3~vvO+SulAN*= zmy!Di;0df@cb9yVvE9Kx`+EAl1$A9@4>+T((%kOJydKdcgAVwsEN=%dBALy0632&G zTdQV=zdy0_oSCPPtvqf=YP{vFwu+SlA}iW7_}&J(Ru;O#`^gLAIv836jz^qs`;9D3 zo_%rs+-l1Q#Tr)Dg_eF+W5Y+!(Y{%F{W;e)k87E;rH(l76+LHKtDkkdt_OdH?UQ2z z?Y32Vjpi?9$+z~$R@#YtG!%ww@O+rMZafBYbQ0g5QPJ7Xv-267VJW8(Wa7h6_X?%$ zvsx#%(&4_4-IRL2{1t~USi#^exiFP%ew2Ap6Lr~pm~uz+*flOzCHU;pw zJ298y^T>H=fh(PmgAmN8kVi3}!eOzf>sd^EetuyYitN&-?Z6Uqa_G+>^E-{D6)ylJ z?!KUeV=V`7WP&Ti3j~7XloTx6pn-+hRr#tLsDo0dYT)zjyU!?dk6pw&+xO7`Ov4Hqz8B}r#d4$VVuXk zo(iF-4INIpx%}aeaVd?iZ%UFXkE9ZGRcIdDl=$P;>-@79lk40i_B4;%NE)YT%rc3| z@P$5F|NE$3w621OUc_%@a=lif3{1jjS%-6Z)`2S6X#EtjGRI$G63&p{o_KP+XtT!% z%&iL660#bc(vBhCYqg_OYxBnIIi54!Ae%Z!M322}f3oSzr2eqk7iXNFb?KL*>zroH zJ#2>?6Qi-QM}7+~T3;FaEb*Rrv`VaK)6%`fmu(T392Tj$k{bfe_6bB=ymeP5S7|$j zpIDdxAGl3bHrDc5CVMS9oUn90Bk;<|s=<8rOF3pXKh5!%aTOsptzRQ$tlphAy0&W3 zwEYz}hlvYYXau=5`Avq)7PCIx)!yq#(<@HD75&=d!zxW&?a`)dr=f4``IACf`h)7V zq^xH}0*yGWYi6!>BaI*b@uNkCuPc}4@2#k4VBr+yN)~YoGKM$IX$TJUR)nLB58XOi zq~kbgbwj;SEyQL48;dHc6K8a^XE2M5<9(F}oasyt#@tG|0yvNYF zD~;nbSj^Ox)ZhG+=E8j=kF z1ZBvYxcvoR@cl*>!50qV&>W&F#Rre?7IZ)y2CDdsu`Pz?cI~c25DHsw=tzJOVlQ$e zzJH*E`+n?#>q`hK`p3zYp~Ud zo)ZZhBuaGPGZAQvE^UXoV0FQ~OMh0h1U+FYIo|G%iuOzbU6&*|uvV9}qrqYxk(E;<*)Ts@ z;#2gisG`rudUA1*7>qv5GY-aMQ23cUylBt)+#U?lD{wM$M6bWvSK`o}8ed z6&g%!nCI<1W-OC*U3hikB)kay{>=Pyjss-8W;w5i4Ti(Auff}57vCmBU>9|!ye7`~ zQ?jP3)!Enagl0$*PQA*Oo~Jd*rhY+xP9L}5w4)$(?8GqF+2=1k*qLXdKS@2?aIBSY zbIHDMqi7#w z&CVG-X6e{)x!^&0n+nL)hAWB>w7)sgrY^tuYE$Oc+R3|iFMqQw`6N?brzbwXVK1S4 z`C0c*lQy!2x>niMehj5tPZD-*0|>fNPrJx=x4*McMMdEx<3NX>U&czMUFP%bZ7t$2 zFc`-?kE!bSI*A!N$u(h&EU_m;ZGy3<75OxCye-J zAPgZtqWT1hXfGl0poavEXyovn10sCq$c$({(Y8r5AgVgRf~pbw8`7>4y&M?9iUnFw z@CfAy%N3ZDVwD1Sn#iHZh9FUz4Ur1Hy?v3kKpzq-8pYD5-6&7N3lb~3N>rt&fB_oC zcr|s)dG7pqG>S-tNa9Fv*eOGl0Fb~FeJr{}gbN5g9i5$MCOw5&$Z@G@50L(#`yi?y zXdrX|&;OQ@zwI{wMkOVJ^dbJDy}W#dFlR8vIEuboCf?Rz=#e94#bZ88`2<{!)b-;` zdn8>74~ygllG%k4UH4ohJE$y2;@$S>IZ0<_Nnt$ON~d+yQ82y&j9D{3Q)^<$$A?TM zrm!f>#D$gz=^WjSRC_s-r11>}$F4jHA`RTNW|psfd{ld?RiWWsn9_Wnv+dN4ZENh= zCfmE*hL2w}o20uoK^y8wx@>xgHUQetg5~z751ulA2W==eVwS)C0BA#(l42O=5;h8T zZNxFl4$o_Lyk=Q3@K(=5=rmJTrtBHw(_oRetoHFmIx;Koo4FLPQE(#!tE@T{W^a_6 z_bACvA!88uJocvyd;M5P{cMUe2Wne*&^7nq0au&jpG4jdEDU>9= zuwq$~o|*lO+Jm_Nf~3`#OAH52wiBJ1qwOe<#Fn@HU4+6189cGNX!@Z{LY*=kxJfW% zc^uCmogASebyme$2?Kef-!?P|BlAuwO!aYGtt<{aVYn*a@5VU&h%-C^OxjeJ7TKEb z{Gc_fvSzO6+cx!O`R}UcMFpIqE*~owSklTBY9Z= zu=Q*+m*|X2_CGqTqqK^bkh)zsbiRGT?T|sl64^N`OB;@;6DOaE&sNFv8%Y8WLq*mt z)lc1iSx1+V+e=vbD(MQG3eSI_g|#=VvP$aJvyo-Yj^>*83chR`GB2}W;viy{pZvh{ zoW13ID}Rrpw!L-x8S7gzxTkEbg-p$jJa5Ke4<3zVy3{zS6ltr7IdY!Zqtr4E75VFL zGxb(sI+_TAjn~`o14cdm2kZ_5yJdf3N9`U7ec*%xy%R8jJd7>Z@4AYv55PfL#KFI? zDg%Q?3@=eQ!tDuFCVD57q4;va97wIIp|%t7Y0p@ewrltP4PLi4?u~~?>3Q(}PX8yU z2%*aSI3)Sr_#vl)ydcSgZOCwm_aB3G6r-qaYx;Yj*_=Fi61^V|8guhYpwpnJg(~y@ z{YNB1e}8{;n2>Y88Ol(Aj@Aye0U%sLUwK}FF$E4SkS5Xf;XndtqR<5WIKYC~gVGty zA>IH%++zVg6wrS+c5H{g99xF7*?K1Pf50w)Jztj`75N8tr`fzAM56WY)}+B~j*fp; zfn;YftVoAc$Q+ejO>IG^JR=#bIyK2tP&LKvv`&(cqi&uW+Ze<1}?1+a#6iMHiY@k6bxZ z1=kTzcD#<0Ns6B5W);Zl*n`nW5W&||;olqi_{`kI_18{0H|Aaw(2d0@xQ?*3Vg|`O ztsuC%Q!-uL47b4>@shKGpygZ(&DUU%!KZkK)Hs{aJ>%U^c}0bu%nZG^gb*}nc@y?- zjG9<8KB*RFJ$-8WBqC(6`N)0uZdfL5h#cad=e5<_Hn@nGLKl6x|8aJ(JT2E`9zpoa zP1h)w>lIvj(>{9GwD5SJjLC)?&Krx3_S4?I8J6P`UzT1Pz+7<9>9!V+xuv&vo)szb zj{B@Sa<%YlnZWbcZ;hXY3}OmrtF>%B87-LciMQ2K&_BvhcZL&pY@>hgkJ|ttz{>p} zu)-cdV;^-M46OFBwFCZG6dS#sHUl z{w>se&^1t{ZQggs^T1s&wZrG(p5OwX;ET9TMEb%?F=|I(7O}~VC;`z#;ekW! z>p(66ttdKCdP2EG3IYW{6^RJwN%#4v{_RN~n1W|FruAt503ev9Ai8n|K>(F2ctr99 zwtxk>j!L=h9{?R%N+dBr1hQ~Q4Er2AiEaY85Dh>X5Cf7x7<~Oba7kJ?8K1v!m%1RM zYv^+E54|%cHb6#;M~+??_xo1g8JQtEM1s=;yxW9q7AzyH?UNz391SbbCW}X#O5SZs z!BTrM!D+-qv8SpK^TM`{S2z-`Z+`pM|J^6Fa82dIgRbxnQ7v51h@}LB*)U+7*D>no zXaHVVQx+10n(|t$nJNvBgVZdGjq>iUd2mvjc|c7-xZ0jI-#*{>-2Amg8})X|YM(!R zL+`wqFP<5Gcd9IM`W9rf(-jM%pGD#A!wKaJ&(Y8w7C?! zqHo{!qrgKMiETK1!~hSu1X2fBf?X9R2#`PVeFq%AfX{t-%_HO|AThm!@&HbKR4-Y4 zyNyK~e9&MR9UjO5Flfa<3wH4!fxuO}xU3rK1PlWhkO1T$pP+SvzzGfk4ALQ>Lc<54 z1@Z~1R!ralCvufnNGS?hX_ghFT)`DETFjoFS4f9I4r2u{Y3r7Zd~D~IN-e-vRsB6^ z{fEDWB5l!MP8QB880cO6K3RzI*WwVOSm}9R%1K%6+jmkiM#cVYpe#H6XQ_|dvBw;e zUP7Js-76z$l@hTe>;+LrkkkulRjqB6ox}o)v_z6M;B9XQ_PJ0ua!*b>7*L!i=Sn58 z0L2eOciiS$<4u>Uli|68`A2!y0nIgKrM6*)3si$^t#U27tvLv4cT?2Y|q_Zmru$?>F9?v!>xP<__yVYGlga&+yE8C-|g7$|xZ(_PIc zjr9^|=ZTiPJWdyEmU+^5+0Xsb{HEhfCBj}bc7@4gK!W-K7?2q->O*n^fwi^N=)JDKe&+IY&QjTdodN5=^5C~e39swbGFa!c=L;!&Z7Jq-QB}IrL z$C)!|`d=y=XND02$-!f;d{;K&hnZ>+q|EjB-_PAIanu8o<-}5%F_gm&B6S`YNe*Mr zl-$cEDxXsw*Uz6bryR8M*AJ%r&PKxKvxG-c7a#XBAW~9MZ z?AUGkh$~s6MaI^UNX*(7K z1*%zitkm0fUyIlketDGIPLASeHBK;Bv+*id$3yOlOad)X)%vble50oD_{FTqRcfBA znRJy|t8)oY--%YrgaJ2fj*h;cEMaA0=@-R6YD2n)j-LBy1u@7jN@KCJ4TEXO4CnhS zd_N$39uZ_B^p!O!{HeirkS*6+$(m0z`VX`e=>}*${1L9>G(~bTsVP|p#g~sCG{)Y5 z3y6UoAcI(sull=S2m}Bcx+|mvfQIiJumA^?UCbg+2vThj&)I;HvKVb9{8w zwUmb^x*&Y}NCNnxy%I#Q2E{jM|DV3W>j@1N?|M)y;m^aT4W7_`poF7TgAPi4!1)4T zLNQiZRfi=Fe3JO0rC%jD03x6YJYlu@1*|@cihv`&eH4Vi3)|4x8$mbK+uH}usDF+B zN53Qu%zte}_Gel3>>nqYmHvZn5=h+YsYv8Jw$Uqx9{C%<}; zN?B7sNTKe%<%Y~jr>v<@8ZC!4b$@$lq{9_hQ@`)7N!$Qy>IWa+G-MryJ#PBv{*H>+ z<&-t`$KK|GD{~(`{QBinfA>pt^WVM^EW-vNL)`<`)CSQFKJsQAu%@;%Y=kwnN29;S zT0C&61#9X+9=uS3^v}SW+7M5cgcv8oh|J_MT#iifUe{6cw&><#79TobM8+D?5^4jN zqcD4en=Ri?IGS~~oOH4q(R#|+WnJrO*R?lW&$#V^@1P)HL|gdkGwa&Ud0fBQc79`W zCtQvmk7&QRqiS9IC9k%d?U(m|fbXD?HL^qGr@p@9YLG!pN5m1cu8zo1yOEtyr(D)| zUJG9v(|P^kuCC4-qJWWI(NSmCcioJ>9@7;Q2aC?w_{Sr= zVwO=)e75?Ao`hV3*q*zEX5BrBrFKTIk}6#`yt-GjHuhC=?XK=uDGdQeuTxvjYXGjP+pGW+4T-jzE}eD$uvX~wAcmCi~}t)9C6bnAVU+wNEI zs|A6hcDd7z9B_TM_QoYca=nrn@nRF(j9deg@`uk9hb>i2(K_ObS0$pC)CE`^5^ z8$A`=2`(Xj%-oHo-ym+@sw@(6ea+{5UZP+)-;8!4(3aHYszrPz<^9}U7)OM+_bz3o ZAFJZt%{oo%yw`sF%j@)ygE+*L{{t~~K+ymI literal 0 HcmV?d00001 diff --git a/frontend/src/features/whats-new/assets/9-mrf-email-notifications.gif b/frontend/src/features/whats-new/assets/9-mrf-email-notifications.gif new file mode 100644 index 0000000000000000000000000000000000000000..437191e01fb2dc16e1d46d1b1c902f5a2e2dc51e GIT binary patch literal 208211 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
;S29>QF9WoXT50>KiX| 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
  • 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 D@PmTQ literal 0 HcmV?d00001 From 3be7636fda47aeb2c08bcaf40627844cdc89fdda Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Fri, 29 Nov 2024 14:59:48 +0800 Subject: [PATCH 2/4] feat: update form create modal response options (#7957) * feat: update form create modal response options * feat: move tile back * feat: move tiling for email mode to rightmost --- .../FormResponseOptions.tsx | 72 +++++++++++-------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/frontend/src/features/workspace/components/CreateFormModal/CreateFormModalContent/FormResponseOptions.tsx b/frontend/src/features/workspace/components/CreateFormModal/CreateFormModalContent/FormResponseOptions.tsx index 401dfa0246..7a79679cb1 100644 --- a/frontend/src/features/workspace/components/CreateFormModal/CreateFormModalContent/FormResponseOptions.tsx +++ b/frontend/src/features/workspace/components/CreateFormModal/CreateFormModalContent/FormResponseOptions.tsx @@ -13,15 +13,32 @@ export interface FormResponseOptionsProps { isSingpass: boolean } -const OptionDescription = ({ listItems = [] }: { listItems: string[] }) => { +interface optionDescriptionIem { + text: string + badge?: string + badgeColorScheme?: string +} + +const OptionDescription = ({ + listItems = [], +}: { + listItems: optionDescriptionIem[] +}) => { return ( <> - {listItems.map((text, index) => ( - - {text} - - ))} + {listItems.map( + ({ text, badge, badgeColorScheme = 'success' }, index) => ( + + {text} + {badge && ( + + {badge} + + )} + + ), + )} ) @@ -47,27 +64,9 @@ export const FormResponseOptions = forwardRef< - - onChange(FormResponseMode.Email)} - flex={1} - > - Email mode form - Receive responses in your inbox only - @@ -75,7 +74,6 @@ export const FormResponseOptions = forwardRef< ref={ref} variant="complex" icon={MultiParty} - badge={New} isActive={value === FormResponseMode.Multirespondent} onClick={() => onChange(FormResponseMode.Multirespondent)} flex={1} @@ -88,11 +86,25 @@ export const FormResponseOptions = forwardRef< + onChange(FormResponseMode.Email)} + flex={1} + > + Email mode form + Receive responses in your inbox only + + ) }) From a0f1a15fe20ca1e6f3e353ed8a5d9907ac198764 Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Fri, 29 Nov 2024 23:46:26 +0800 Subject: [PATCH 3/4] feat(twilio): remove twilio be (#7870) * feat: remove twilio from fe * chore: restore shared files as they are used by BE * feat: remove twilio from be pass 1 * test: verification.service.spec.ts * test: admin-form.service.spec.ts * fix: remove deleted files * fix: lint issues * fix: verification routes * chore: fix lint errors * Revert "chore: restore shared files as they are used by BE" This reverts commit bc8ee76536692b03e04a4eac001fc577d8235e20. * feat: remove twilio verifications * chore: fix lint issues * chore: remove retrievePublicFormsWithSmsVerification func * chore: fix lint issues * feat: remove get/set for msgSrvcName, remove aws ssm * chore: remove stoplight/prism * feat: remove twilioCredentials from i18n * feat: remove msgSrvcName * chore: document msgSrvcName in code * fix: remove unused import * feat: update docs to mark msgSrvcName as legacy --------- Co-authored-by: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> --- .template-env | 7 - __tests__/integration/helpers/twilio.ts | 20 - __tests__/unit/backend/helpers/jest-db.ts | 45 -- docker-compose.yml | 12 +- frontend/src/constants/links.ts | 1 - .../admin-form/common/AdminViewFormService.ts | 1 - .../features/whats-new/FeatureUpdateList.ts | 9 - .../admin-form/sidebar/fields/en-sg.ts | 6 - .../admin-form/sidebar/fields/index.ts | 6 - package-lock.json | 489 +------------ package.json | 2 - shared/types/form/form.ts | 10 +- shared/types/twilio.ts | 6 - shared/utils/verification.ts | 30 - src/app/config/config.ts | 1 - src/app/config/features/sms.config.ts | 47 -- src/app/config/schema.ts | 7 - .../__tests__/form.server.model.spec.ts | 99 +-- src/app/models/form.server.model.ts | 45 +- src/app/modules/core/core.errors.ts | 6 - .../form/__tests__/form.service.spec.ts | 36 - .../__tests__/admin-form.controller.spec.ts | 656 +----------------- .../__tests__/admin-form.service.spec.ts | 166 +---- .../form/admin-form/admin-form.controller.ts | 181 ----- .../form/admin-form/admin-form.service.ts | 335 +-------- .../form/admin-form/admin-form.utils.ts | 48 -- src/app/modules/form/form.service.ts | 36 - src/app/modules/form/form.utils.ts | 29 - .../__tests__/twilio.controller.spec.ts | 98 --- src/app/modules/twilio/twilio.controller.ts | 110 --- .../modules/twilio/twilio.statsd-client.ts | 5 - .../__tests__/verification.service.spec.ts | 119 +--- .../verification/verification.service.ts | 29 +- .../modules/verification/verification.util.ts | 5 - .../admin-forms.twilio.routes.spec.ts | 412 ----------- .../v3/admin/forms/admin-forms.form.routes.ts | 14 - .../api/v3/admin/forms/admin-forms.routes.ts | 2 - .../admin/forms/admin-forms.twilio.routes.ts | 30 - .../public-forms.verification.routes.spec.ts | 68 +- .../__tests__/notifications.routes.spec.ts | 82 --- .../v3/notifications/notifications.routes.ts | 14 - .../mail/__tests__/mail.service.spec.ts | 315 +-------- src/app/services/mail/mail.service.ts | 305 +------- src/app/services/mail/mail.types.ts | 28 - src/app/services/mail/mail.utils.ts | 68 -- .../sms/__tests__/sms.factory.spec.ts | 64 -- .../sms/__tests__/sms.service.spec.ts | 200 ------ .../__tests__/sms_count.server.model.spec.ts | 649 ----------------- src/app/services/sms/sms.dev.prismclient.ts | 30 - src/app/services/sms/sms.factory.ts | 49 -- src/app/services/sms/sms.service.ts | 385 ---------- src/app/services/sms/sms.types.ts | 160 ----- .../services/sms/sms_count.server.model.ts | 186 ----- src/app/utils/formatters.ts | 5 - ...rification-disabled-admin.server.view.html | 38 - ...ification-disabled-collab.server.view.html | 30 - .../sms-verification-warning-admin.view.html | 31 - .../sms-verification-warning-collab.view.html | 28 - .../sms-verification-warning.view.html | 31 - src/types/config.ts | 2 - src/types/form.ts | 36 +- src/types/index.ts | 1 - src/types/twilio.ts | 19 - 63 files changed, 79 insertions(+), 5905 deletions(-) delete mode 100644 __tests__/integration/helpers/twilio.ts delete mode 100644 shared/types/twilio.ts delete mode 100644 src/app/config/features/sms.config.ts delete mode 100644 src/app/modules/twilio/__tests__/twilio.controller.spec.ts delete mode 100644 src/app/modules/twilio/twilio.controller.ts delete mode 100644 src/app/modules/twilio/twilio.statsd-client.ts delete mode 100644 src/app/routes/api/v3/admin/forms/__tests__/admin-forms.twilio.routes.spec.ts delete mode 100644 src/app/routes/api/v3/admin/forms/admin-forms.twilio.routes.ts delete mode 100644 src/app/routes/api/v3/notifications/__tests__/notifications.routes.spec.ts delete mode 100644 src/app/services/sms/__tests__/sms.factory.spec.ts delete mode 100644 src/app/services/sms/__tests__/sms.service.spec.ts delete mode 100644 src/app/services/sms/__tests__/sms_count.server.model.spec.ts delete mode 100644 src/app/services/sms/sms.dev.prismclient.ts delete mode 100644 src/app/services/sms/sms.factory.ts delete mode 100644 src/app/services/sms/sms.service.ts delete mode 100644 src/app/services/sms/sms.types.ts delete mode 100644 src/app/services/sms/sms_count.server.model.ts delete mode 100644 src/app/utils/formatters.ts delete mode 100644 src/app/views/templates/sms-verification-disabled-admin.server.view.html delete mode 100644 src/app/views/templates/sms-verification-disabled-collab.server.view.html delete mode 100644 src/app/views/templates/sms-verification-warning-admin.view.html delete mode 100644 src/app/views/templates/sms-verification-warning-collab.view.html delete mode 100644 src/app/views/templates/sms-verification-warning.view.html delete mode 100644 src/types/twilio.ts diff --git a/.template-env b/.template-env index 44d9bf1cf1..e3d4b5dd20 100644 --- a/.template-env +++ b/.template-env @@ -68,12 +68,6 @@ FORMSG_SDK_MODE= ## If the variable exists, the [verified] feature will be enabled. # VERIFICATION_SECRET_KEY= -## Twilio -## If the below variables exists, the [sms] feature will be enabled. -# TWILIO_ACCOUNT_SID= -# TWILIO_API_KEY= -# TWILIO_API_SECRET= -# TWILIO_MESSAGING_SERVICE_SID= ## SingPass, CorpPass related environment variables # If you are not a member of the Singapore Government, you can safely exclude @@ -113,7 +107,6 @@ FORMSG_SDK_MODE= # Used to check if BE Server is currently running on local development environment # One of boolean: "true" | "false" -# USE_MOCK_TWILIO= # USE_MOCK_POSTMAN_SMS= # POSTMAN_MOP_CAMPAIGN_ID= diff --git a/__tests__/integration/helpers/twilio.ts b/__tests__/integration/helpers/twilio.ts deleted file mode 100644 index 1517f22a8e..0000000000 --- a/__tests__/integration/helpers/twilio.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { jest } from '@jest/globals' -import { Twilio } from 'twilio' - -// Mocks the underlying twilio import -// This allows us to inject values into twilio dynamically without having to be verbose -// This is casted for type annotations -const twilio = { - messages: { - create: jest.fn(), - }, -} as unknown as Twilio - -// Mocks the default twilio argument -jest.mock('twilio', () => () => twilio) - -const MockTwilio = jest.mocked(twilio) - -jest.mock('src/app/services/sms/sms.dev.prismclient', () => () => ({})) - -export default MockTwilio diff --git a/__tests__/unit/backend/helpers/jest-db.ts b/__tests__/unit/backend/helpers/jest-db.ts index 373aec4adf..4851d64a31 100644 --- a/__tests__/unit/backend/helpers/jest-db.ts +++ b/__tests__/unit/backend/helpers/jest-db.ts @@ -233,50 +233,6 @@ const insertEncryptForm = async ({ } } -const insertFormWithMsgSrvcName = async ({ - formId, - userId, - mailDomain = 'test.gov.sg', - mailName = 'test', - shortName = 'govtest', - formOptions = {}, - msgSrvcName = 'mockMsgSrvcname', -}: { - formId?: Schema.Types.ObjectId - userId?: Schema.Types.ObjectId - mailName?: string - mailDomain?: string - shortName?: string - formOptions?: Partial - msgSrvcName?: string -} = {}): Promise<{ - form: IFormSchema - user: IUserSchema - agency: IAgencySchema -}> => { - const { user, agency } = await insertFormCollectionReqs({ - userId, - mailDomain, - mailName, - shortName, - }) - - const FormModel = getFormModel(mongoose) - const form = await FormModel.create({ - title: 'example form title', - admin: user._id, - _id: formId, - ...formOptions, - msgSrvcName: msgSrvcName, - }) - - return { - form: form as IFormSchema, - user, - agency, - } -} - const getFullFormById = async ( formId: string, ): Promise => @@ -344,7 +300,6 @@ const dbHandler = { clearCollection, insertEmailForm, insertEncryptForm, - insertFormWithMsgSrvcName, getFullFormById, insertFormSubmission, insertFormFeedback, diff --git a/docker-compose.yml b/docker-compose.yml index 0a91a3a175..ee7b758a11 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,11 +53,6 @@ services: # Keep in sync with the development key in # https://github.com/opengovsg/formsg-javascript-sdk/blob/develop/src/resource/signing-keys.ts - SIGNING_SECRET_KEY=HDBXpu+2/gu10bLHpy8HjpN89xbA6boH9GwibPGJA8BOXmB+zOUpxCP33/S5p8vBWlPokC7gLR0ca8urVwfMUQ== - # Mock Twilio credentials. SMSes do not work in dev environment. - - TWILIO_ACCOUNT_SID=AC00000000000000000000000000000000 - - TWILIO_API_KEY=mockTwilioApiKey - - TWILIO_API_SECRET=mockTwilioApiSecret - - TWILIO_MESSAGING_SERVICE_SID=MG00000000000000000000000000000000 - SP_OIDC_NDI_DISCOVERY_ENDPOINT=http://localhost:5156/singpass/v2/.well-known/openid-configuration - SP_OIDC_NDI_JWKS_ENDPOINT=http://localhost:5156/singpass/v2/.well-known/keys - SP_OIDC_RP_CLIENT_ID=rpClientId @@ -167,7 +162,7 @@ services: depends_on: - backend environment: - - SERVICES=s3,sqs,secretsmanager + - SERVICES=s3,sqs - DNS_ADDRESS=0 - EXTRA_CORS_ALLOWED_ORIGINS=http://localhost:5173 volumes: @@ -189,11 +184,6 @@ services: - STRIPE_API_KEY - STRIPE_DEVICE_NAME=StripeWebhookListener - mocktwilio: - image: stoplight/prism:4 - network_mode: 'service:backend' # reuse backend service's network stack so that it can resolve localhost:4010 to prismtwilio:4010 - command: mock https://raw.githubusercontent.com/twilio/twilio-oai/main/spec/json/twilio_api_v2010.json - volumes: mongodb_data: driver: local diff --git a/frontend/src/constants/links.ts b/frontend/src/constants/links.ts index d2e52c3e13..f6accc4c8e 100644 --- a/frontend/src/constants/links.ts +++ b/frontend/src/constants/links.ts @@ -22,7 +22,6 @@ export const GUIDE_SPCP_ESRVCID = 'https://go.gov.sg/formsg-guide-singpass-myinfo' export const GUIDE_ENABLE_SPCP = 'https://go.gov.sg/formsg-guide-singpass-myinfo-enable' -export const GUIDE_TWILIO = 'https://go.gov.sg/formsg-guide-verified-smses' export const GUIDE_ATTACHMENT_SIZE_LIMIT = 'https://go.gov.sg/formsg-guide-attachments' export const GUIDE_E2EE = 'https://go.gov.sg/formsg-guide-e2e' diff --git a/frontend/src/features/admin-form/common/AdminViewFormService.ts b/frontend/src/features/admin-form/common/AdminViewFormService.ts index 423cd24bd5..2e6c40d5c9 100644 --- a/frontend/src/features/admin-form/common/AdminViewFormService.ts +++ b/frontend/src/features/admin-form/common/AdminViewFormService.ts @@ -6,7 +6,6 @@ import { FormPermissionsDto, PermissionsUpdateDto, PreviewFormViewDto, - SmsCountsDto, } from '~shared/types/form/form' import { ADMINFORM_USETEMPLATE_ROUTE } from '~constants/routes' diff --git a/frontend/src/features/whats-new/FeatureUpdateList.ts b/frontend/src/features/whats-new/FeatureUpdateList.ts index 9dd4f8b42c..05dd33d1c0 100644 --- a/frontend/src/features/whats-new/FeatureUpdateList.ts +++ b/frontend/src/features/whats-new/FeatureUpdateList.ts @@ -150,14 +150,5 @@ export const FEATURE_UPDATE_LIST: FeatureUpdateList = { alt: 'The new FormSG experience', }, }, - { - title: 'Big little improvements', - date: new Date('12 October 2022 GMT+8'), - description: dedent` - * Easily paste options into Radio fields - * Add your Twilio credentials so form-fillers can verify their mobile number - * Enhanced security to prevent malicious inputs in form responses, [read more about it here](https://formsg.gitbook.io/form-user-guide/faq/faq/storage-mode#why-do-i-have-an-additional-quote-in-some-of-my-responses) - `, - }, ], } diff --git a/frontend/src/i18n/locales/features/admin-form/sidebar/fields/en-sg.ts b/frontend/src/i18n/locales/features/admin-form/sidebar/fields/en-sg.ts index 99bc3c1770..73b8f7eb67 100644 --- a/frontend/src/i18n/locales/features/admin-form/sidebar/fields/en-sg.ts +++ b/frontend/src/i18n/locales/features/admin-form/sidebar/fields/en-sg.ts @@ -105,12 +105,6 @@ export const enSG: Fields = { }, allowInternationalNumber: 'Allow international numbers', smsCounts: 'SMSes used', - twilioCredentials: { - success: 'Twilio credentials added', - exceedQuota: 'You have reached the free tier limit for SMS verification.', - noCredentials: 'Twilio credentials not added.', - cta: 'Add credentials now', - }, }, date: { dateValidation: { diff --git a/frontend/src/i18n/locales/features/admin-form/sidebar/fields/index.ts b/frontend/src/i18n/locales/features/admin-form/sidebar/fields/index.ts index c15b57a73b..b369be54e0 100644 --- a/frontend/src/i18n/locales/features/admin-form/sidebar/fields/index.ts +++ b/frontend/src/i18n/locales/features/admin-form/sidebar/fields/index.ts @@ -103,12 +103,6 @@ export interface Fields { } allowInternationalNumber: string smsCounts: string - twilioCredentials: { - success: string - exceedQuota: string - noCredentials: string - cta: string - } } date: { dateValidation: { diff --git a/package-lock.json b/package-lock.json index f627f8630a..a299e4380e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,7 +98,6 @@ "ts-essentials": "^10.0.1", "tweetnacl": "^1.0.1", "tweetnacl-util": "^0.15.1", - "twilio": "~4.19.3", "uid-generator": "^2.0.0", "ulid": "^2.3.0", "uuid": "^10.0.0", @@ -114,7 +113,6 @@ "@babel/preset-env": "^7.25.4", "@opengovsg/mockpass": "^4.3.4", "@playwright/test": "^1.49.0", - "@stoplight/prism-cli": "^5.10.0", "@types/bcrypt": "^5.0.0", "@types/bluebird": "^3.5.42", "@types/busboy": "^1.5.4", @@ -5272,66 +5270,6 @@ "version": "2.1.0", "license": "Apache-2.0" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", - "integrity": "sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.11.tgz", - "integrity": "sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz", - "integrity": "sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.11.tgz", - "integrity": "sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@esbuild/darwin-arm64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz", @@ -5347,276 +5285,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz", - "integrity": "sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz", - "integrity": "sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz", - "integrity": "sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz", - "integrity": "sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz", - "integrity": "sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz", - "integrity": "sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz", - "integrity": "sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz", - "integrity": "sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==", - "cpu": [ - "mips64el" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz", - "integrity": "sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz", - "integrity": "sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz", - "integrity": "sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz", - "integrity": "sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz", - "integrity": "sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz", - "integrity": "sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz", - "integrity": "sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz", - "integrity": "sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz", - "integrity": "sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz", - "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -6737,126 +6405,6 @@ "node": ">= 10" } }, - "node_modules/@next/swc-darwin-x64": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.10.tgz", - "integrity": "sha512-Y0TC+FXbFUQ2MQgimJ/7Ina2mXIKhE7F+GUe1SgnzRmwFY3hX2z8nyVCxE82I2RicspdkZnSWMn4oTjIKz4uzA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.10.tgz", - "integrity": "sha512-ZfQ7yOy5zyskSj9rFpa0Yd7gkrBnJTkYVSya95hX3zeBG9E55Z6OTNPn1j2BTFWvOVVj65C3T+qsjOyVI9DQpA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.10.tgz", - "integrity": "sha512-n2i5o3y2jpBfXFRxDREr342BGIQCJbdAUi/K4q6Env3aSx8erM9VuKXHw5KNROK9ejFSPf0LhoSkU/ZiNdacpQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.10.tgz", - "integrity": "sha512-GXvajAWh2woTT0GKEDlkVhFNxhJS/XdDmrVHrPOA83pLzlGPQnixqxD8u3bBB9oATBKB//5e4vpACnx5Vaxdqg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.10.tgz", - "integrity": "sha512-opFFN5B0SnO+HTz4Wq4HaylXGFV+iHrVxd3YvREUX9K+xfc4ePbRrxqOuPOFjtSuiVouwe6uLeDtabjEIbkmDA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.10.tgz", - "integrity": "sha512-9NUzZuR8WiXTvv+EiU/MXdcQ1XUvFixbLIMNQiVHuzs7ZIFrJDLJDaOF1KaqttoTujpcxljM/RNAOmw1GhPPQQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.10.tgz", - "integrity": "sha512-fr3aEbSd1GeW3YUMBkWAu4hcdjZ6g4NBl1uku4gAn661tcxd1bHs1THWYzdsbTRLcCKLjrDZlNp6j2HTfrw+Bg==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.10.tgz", - "integrity": "sha512-UjeVoRGKNL2zfbcQ6fscmgjBAS/inHBh63mjIlfPg/NG8Yn2ztqylXt5qilYb6hoHIwaU2ogHknHWWmahJjgZQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -12379,11 +11927,6 @@ "url": "https://github.com/sponsors/kossnocorp" } }, - "node_modules/dayjs": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", - "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" - }, "node_modules/dc-polyfill": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/dc-polyfill/-/dc-polyfill-0.1.6.tgz", @@ -22405,6 +21948,7 @@ }, "node_modules/querystringify": { "version": "2.2.0", + "dev": true, "license": "MIT" }, "node_modules/queue-tick": { @@ -22879,6 +22423,7 @@ }, "node_modules/requires-port": { "version": "1.0.0", + "dev": true, "license": "MIT" }, "node_modules/resolve": { @@ -23143,10 +22688,6 @@ "loose-envify": "^1.1.0" } }, - "node_modules/scmp": { - "version": "2.1.0", - "license": "BSD-3-Clause" - }, "node_modules/selderee": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", @@ -24630,31 +24171,6 @@ "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" }, - "node_modules/twilio": { - "version": "4.19.3", - "resolved": "https://registry.npmjs.org/twilio/-/twilio-4.19.3.tgz", - "integrity": "sha512-3X5Czl9Vg4QFl+2pnfMQ+H8YfEDQ4WeuAmqjUpbK65x0DfmxTCHuPEFWUKVZCJZew6iltJB/1whhVvIKETe54A==", - "dependencies": { - "axios": "^1.6.0", - "dayjs": "^1.11.9", - "https-proxy-agent": "^5.0.0", - "jsonwebtoken": "^9.0.0", - "qs": "^6.9.4", - "scmp": "^2.1.0", - "url-parse": "^1.5.9", - "xmlbuilder": "^13.0.2" - }, - "engines": { - "node": ">=14.0" - } - }, - "node_modules/twilio/node_modules/xmlbuilder": { - "version": "13.0.2", - "license": "MIT", - "engines": { - "node": ">=6.0" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -25007,6 +24523,7 @@ }, "node_modules/url-parse": { "version": "1.5.10", + "dev": true, "license": "MIT", "dependencies": { "querystringify": "^2.1.1", diff --git a/package.json b/package.json index a6d151a9f6..2341cee8d9 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,6 @@ "ts-essentials": "^10.0.1", "tweetnacl": "^1.0.1", "tweetnacl-util": "^0.15.1", - "twilio": "~4.19.3", "uid-generator": "^2.0.0", "ulid": "^2.3.0", "uuid": "^10.0.0", @@ -160,7 +159,6 @@ "@babel/preset-env": "^7.25.4", "@opengovsg/mockpass": "^4.3.4", "@playwright/test": "^1.49.0", - "@stoplight/prism-cli": "^5.10.0", "@types/bcrypt": "^5.0.0", "@types/bluebird": "^3.5.42", "@types/busboy": "^1.5.4", diff --git a/shared/types/form/form.ts b/shared/types/form/form.ts index a704814a81..d0421f8258 100644 --- a/shared/types/form/form.ts +++ b/shared/types/form/form.ts @@ -161,6 +161,11 @@ export interface FormBase { esrvcId?: string + /** + * LEGACY: Was previously used for sending with the correct Twilio. + * @deprecated Twilio support is removed and replaced with postman-sms. + * This is retained since DB records may still contain this field for backward compatibility. + */ msgSrvcName?: string webhook: FormWebhook @@ -328,11 +333,6 @@ export type PublicFormViewDto = { export type PreviewFormViewDto = Pick -export type SmsCountsDto = { - quota: number - freeSmsCounts: number -} - export type AdminFormViewDto = { form: AdminFormDto } diff --git a/shared/types/twilio.ts b/shared/types/twilio.ts deleted file mode 100644 index 9c084aa39d..0000000000 --- a/shared/types/twilio.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface TwilioCredentials { - accountSid: string - apiKey: string - apiSecret: string - messagingServiceSid: string -} diff --git a/shared/utils/verification.ts b/shared/utils/verification.ts index 110cf3ac5f..0b2114481b 100644 --- a/shared/utils/verification.ts +++ b/shared/utils/verification.ts @@ -13,33 +13,3 @@ export const MAX_OTP_REQUESTS = 10 */ export const WAIT_FOR_OTP_TOLERANCE_SECONDS = 2 export const NUM_OTP_RETRIES = 4 - -export enum VfnErrors { - ResendOtp = 'RESEND_OTP', - SendOtpFailed = 'SEND_OTP_FAILED', - WaitForOtp = 'WAIT_FOR_OTP', - InvalidOtp = 'INVALID_OTP', - FieldNotFound = 'FIELD_NOT_FOUND', - TransactionNotFound = 'TRANSACTION_NOT_FOUND', - InvalidMobileNumber = 'INVALID_MOBILE_NUMBER', -} - -export enum ADMIN_VERIFIED_SMS_STATES { - limitExceeded = 'LIMIT_EXCEEDED', - belowLimit = 'BELOW_LIMIT', - hasMessageServiceId = 'MESSAGE_SERVICE_ID_OBTAINED', -} - -export enum SMS_WARNING_TIERS { - LOW = 2500, - MED = 5000, - HIGH = 7500, -} - -export const stringifiedSmsWarningTiers: { - [K in keyof typeof SMS_WARNING_TIERS]: string -} = { - LOW: '2.5K', - MED: '5K', - HIGH: '7.5K', -} diff --git a/src/app/config/config.ts b/src/app/config/config.ts index e6f09c13bf..00dfd15985 100644 --- a/src/app/config/config.ts +++ b/src/app/config/config.ts @@ -236,7 +236,6 @@ const config: Config = { isDev, isTest, isDevOrTest, - useMockTwilio: basicVars.core.useMockTwilio, useMockPostmanSms: basicVars.core.useMockPostmanSms, nodeEnv, formsgSdkMode: basicVars.formsgSdkMode, diff --git a/src/app/config/features/sms.config.ts b/src/app/config/features/sms.config.ts deleted file mode 100644 index f0649b58ee..0000000000 --- a/src/app/config/features/sms.config.ts +++ /dev/null @@ -1,47 +0,0 @@ -import convict, { Schema } from 'convict' - -export interface ISms { - twilioAccountSid: string - twilioApiKey: string - twilioApiSecret: string - twilioMsgSrvcSid: string - smsVerificationLimit: number -} - -const smsSchema: Schema = { - twilioAccountSid: { - doc: 'Twilio messaging ID', - format: String, - default: null, - env: 'TWILIO_ACCOUNT_SID', - }, - twilioApiKey: { - doc: 'Twilio standard API Key', - format: String, - default: null, - env: 'TWILIO_API_KEY', - }, - twilioApiSecret: { - doc: 'Twilio API Secret', - format: String, - default: null, - env: 'TWILIO_API_SECRET', - }, - twilioMsgSrvcSid: { - doc: 'Messaging service ID', - format: String, - default: null, - env: 'TWILIO_MESSAGING_SERVICE_SID', - }, - smsVerificationLimit: { - doc: 'Sms verification limit for an admin', - // Positive int - format: 'nat', - default: 10000, - env: 'SMS_VERIFICATION_LIMIT', - }, -} - -export const smsConfig = convict(smsSchema) - .validate({ allowed: 'strict' }) - .getProperties() diff --git a/src/app/config/schema.ts b/src/app/config/schema.ts index 6c4d252606..7870dc5b48 100644 --- a/src/app/config/schema.ts +++ b/src/app/config/schema.ts @@ -329,13 +329,6 @@ export const optionalVarsSchema: Schema = { default: Environment.Prod, env: 'NODE_ENV', }, - // TODO(ken): to remove after twilio is no longer used - useMockTwilio: { - doc: 'Enables twilio API mocking and directs SMS body over to maildev', - format: 'Boolean', - default: false, - env: 'USE_MOCK_TWILIO', - }, useMockPostmanSms: { doc: 'Enables Postman SMS API mocking and directs SMS body over to maildev', format: 'Boolean', diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index d731381418..58eef8b335 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -2,7 +2,7 @@ import { generateDefaultField } from '__tests__/unit/backend/helpers/generate-form-data' import dbHandler from '__tests__/unit/backend/helpers/jest-db' import { ObjectId } from 'bson' -import { cloneDeep, map, merge, omit, orderBy, pick, range } from 'lodash' +import { cloneDeep, map, merge, omit, orderBy, pick } from 'lodash' import mongoose, { Types } from 'mongoose' import { EMAIL_PUBLIC_FORM_FIELDS, @@ -1409,11 +1409,7 @@ describe('Form Model', () => { it('should return otpData of an email form when formId is valid', async () => { // Arrange - const emailFormParams = merge({}, MOCK_EMAIL_FORM_PARAMS, { - msgSrvcName: 'mockSrvcName', - }) - // Create a form with msgSrvcName - const form = await Form.create(emailFormParams) + const form = await Form.create(MOCK_EMAIL_FORM_PARAMS) // Act const actualOtpData = await Form.getOtpData(form._id) @@ -1428,18 +1424,13 @@ describe('Form Model', () => { email: populatedAdmin.email, userId: populatedAdmin._id, }, - msgSrvcName: emailFormParams.msgSrvcName, } expect(actualOtpData).toEqual(expectedOtpData) }) it('should return otpData of an encrypt form when formId is valid', async () => { // Arrange - const encryptFormParams = merge({}, MOCK_ENCRYPTED_FORM_PARAMS, { - msgSrvcName: 'mockSrvcName', - }) - // Create a form with msgSrvcName - const form = await Form.create(encryptFormParams) + const form = await Form.create(MOCK_ENCRYPTED_FORM_PARAMS) // Act const actualOtpData = await Form.getOtpData(form._id) @@ -1454,7 +1445,6 @@ describe('Form Model', () => { email: populatedAdmin.email, userId: populatedAdmin._id, }, - msgSrvcName: encryptFormParams.msgSrvcName, } expect(actualOtpData).toEqual(expectedOtpData) }) @@ -2198,55 +2188,6 @@ describe('Form Model', () => { await expect(Form.countDocuments()).resolves.toEqual(0) }) }) - - describe('retrievePublicFormsWithSmsVerification', () => { - const MOCK_MSG_SRVC_NAME = 'mockTwilioName' - it('should retrieve only public forms with verifiable mobile fields that are not onboarded', async () => { - // Arrange - const mockFormPromises = range(8).map((_, idx) => { - // Extract bits and use them to represent state - const isPublic = !!(idx % 2) - const isVerifiable = !!((idx >> 1) % 2) - const isOnboarded = !!((idx >> 2) % 2) - return Form.create({ - admin: populatedAdmin._id, - responseMode: FormResponseMode.Email, - title: 'mock mobile form', - emails: [populatedAdmin.email], - status: isPublic ? FormStatus.Public : FormStatus.Private, - ...(isOnboarded && { msgSrvcName: MOCK_MSG_SRVC_NAME }), - form_fields: [ - generateDefaultField(BasicField.Mobile, { isVerifiable }), - ], - }) - }) - await Promise.all(mockFormPromises) - - // Act - const forms = await Form.retrievePublicFormsWithSmsVerification( - populatedAdmin._id, - ) - - // Assert - expect(forms.length).toBe(1) - expect(forms[0].form_fields[0].isVerifiable).toBe(true) - expect(forms[0].status).toBe(FormStatus.Public) - expect(forms[0].msgSrvcName).toBeUndefined() - }) - - it('should return an empty array when there are no forms', async () => { - // NOTE: This is an edge case and should never happen in prod as this method is called when - // a public form has a certain amount of verifications - - // Act - const forms = await Form.retrievePublicFormsWithSmsVerification( - populatedAdmin._id, - ) - - // Assert - expect(forms.length).toBe(0) - }) - }) }) describe('Methods', () => { @@ -3020,39 +2961,5 @@ describe('Form Model', () => { ) }) }) - - describe('updateMsgSrvcName', () => { - const MOCK_MSG_SRVC_NAME = 'mockTwilioName' - it('should update msgSrvcName of form to new msgSrvcName', async () => { - // Arrange - const form = await Form.create({ - admin: populatedAdmin._id, - title: 'mock mobile form', - }) - - // Act - const updatedForm = await form.updateMsgSrvcName(MOCK_MSG_SRVC_NAME) - // Assert - expect(updatedForm?.msgSrvcName).toBe(MOCK_MSG_SRVC_NAME) - }) - }) - - describe('deleteMsgSrvcName', () => { - const MOCK_MSG_SRVC_NAME = 'mockTwilioName' - it('should delete msgSrvcName of form', async () => { - // Arrange - const form = await Form.create({ - admin: populatedAdmin._id, - title: 'mock mobile form', - msgSrvcName: MOCK_MSG_SRVC_NAME, - }) - - // Act - const updatedForm = await form.deleteMsgSrvcName() - - // Assert - expect(updatedForm?.msgSrvcName).toBeUndefined() - }) - }) }) }) diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index af03450619..aceaa61567 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -657,6 +657,11 @@ const compileFormModel = (db: Mongoose): IFormModel => { }, }, + /** + * LEGACY: Was previously used for sending with the correct Twilio. + * @deprecated Twilio support is removed and replaced with postman-sms. + * This is retained since DB records may still contain this field for backward compatibility. + */ msgSrvcName: { // Name of credentials for messaging service, stored in secrets manager type: String, @@ -808,22 +813,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { return this.save() } - FormSchema.methods.updateMsgSrvcName = async function ( - msgSrvcName: string, - session?: ClientSession, - ) { - this.msgSrvcName = msgSrvcName - - return this.save({ session }) - } - - FormSchema.methods.deleteMsgSrvcName = async function ( - session?: ClientSession, - ) { - this.msgSrvcName = undefined - return this.save({ session }) - } - const FormDocumentSchema = FormSchema as unknown as Schema FormDocumentSchema.methods.getDashboardView = function ( @@ -1049,7 +1038,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { // Method to retrieve data for OTP verification FormSchema.statics.getOtpData = async function (formId: string) { try { - const data = await this.findById(formId, 'msgSrvcName admin').populate({ + const data = await this.findById(formId, 'admin').populate({ path: 'admin', select: 'email', }) @@ -1060,7 +1049,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { email: data.admin.email, userId: data.admin._id, }, - msgSrvcName: data.msgSrvcName, } as FormOtpData) : null } catch { @@ -1256,27 +1244,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { ).exec() } - /** - * Retrieves all the public forms for a user which has sms verifications enabled - * This only retrieves forms that are using FormSG credentials - * @param userId The userId to retrieve the forms for - * @returns All public forms that have sms verifications enabled - */ - FormSchema.statics.retrievePublicFormsWithSmsVerification = async function ( - userId: IUserSchema['_id'], - ) { - return this.find({ - admin: userId, - 'form_fields.fieldType': BasicField.Mobile, - 'form_fields.isVerifiable': true, - status: FormStatus.Public, - msgSrvcName: { - $exists: false, - }, - }) - .read('secondary') - .exec() - } FormSchema.statics.getGoLinkSuffix = async function (formId: string) { return this.findById(formId, 'goLinkSuffix').exec() } diff --git a/src/app/modules/core/core.errors.ts b/src/app/modules/core/core.errors.ts index f412756124..f07b94711d 100644 --- a/src/app/modules/core/core.errors.ts +++ b/src/app/modules/core/core.errors.ts @@ -325,12 +325,6 @@ export class SecretsManagerConflictError extends ApplicationError { } } -export class TwilioCacheError extends ApplicationError { - constructor(message?: string) { - super(message, undefined, ErrorCodes.TWILIO_CACHE) - } -} - /** * Union of all possible database errors */ diff --git a/src/app/modules/form/__tests__/form.service.spec.ts b/src/app/modules/form/__tests__/form.service.spec.ts index 2af82d0098..aca33e49f1 100644 --- a/src/app/modules/form/__tests__/form.service.spec.ts +++ b/src/app/modules/form/__tests__/form.service.spec.ts @@ -456,40 +456,4 @@ describe('FormService', () => { expect(actual._unsafeUnwrapErr()).toEqual(new ApplicationError()) }) }) - - describe('retrievePublicFormsWithSmsVerification', () => { - it('should call the db method successfully', async () => { - // Arrange - const retrieveFormSpy = jest - .spyOn(Form, 'retrievePublicFormsWithSmsVerification') - .mockResolvedValueOnce([]) - const MOCK_ADMIN_ID = MOCK_ADMIN_OBJ_ID.toString() - const expected: IFormSchema[] = [] - - // Act - const actual = - await FormService.retrievePublicFormsWithSmsVerification(MOCK_ADMIN_ID) - - // Assert - expect(actual._unsafeUnwrap()).toEqual(expected) - expect(retrieveFormSpy).toHaveBeenCalledWith(MOCK_ADMIN_ID) - }) - - it('should propagate the error received when error occurs while querying', async () => { - // Arrange - const expected = new DatabaseError('whoops') - const retrieveFormSpy = jest - .spyOn(Form, 'retrievePublicFormsWithSmsVerification') - .mockRejectedValueOnce(expected) - const MOCK_ADMIN_ID = MOCK_ADMIN_OBJ_ID.toString() - - // Act - const actual = - await FormService.retrievePublicFormsWithSmsVerification(MOCK_ADMIN_ID) - - // Assert - expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseError) - expect(retrieveFormSpy).toHaveBeenCalledWith(MOCK_ADMIN_ID) - }) - }) }) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index 74c50f2e1e..eb07770029 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -42,7 +42,6 @@ import { MailSendError, } from 'src/app/services/mail/mail.errors' import MailService from 'src/app/services/mail/mail.service' -import { TwilioCredentials } from 'src/app/services/sms/sms.types' import { CreatePresignedPostError } from 'src/app/utils/aws-s3' import { EditFieldActions } from 'src/shared/constants' import { @@ -77,8 +76,6 @@ import { LogicDto, } from '../../../../../../shared/types' import * as CryptoUtil from '../../../../../../shared/utils/crypto' -import { smsConfig } from '../../../../config/features/sms.config' -import * as SmsService from '../../../../services/sms/sms.service' import ParsedResponsesObject from '../../../submission/ParsedResponsesObject.class' import * as UserService from '../../../user/user.service' import { @@ -135,8 +132,6 @@ jest.mock('../../../user/user.service') const MockUserService = jest.mocked(UserService) jest.mock('src/app/services/mail/mail.service') const MockMailService = jest.mocked(MailService) -jest.mock('../../../../services/sms/sms.service') -const MockSmsService = jest.mocked(SmsService) jest.mock('src/app/modules/workspace/workspace.service.ts') const MockWorkspaceService = jest.mocked(WorkspaceService) @@ -436,6 +431,7 @@ describe('admin-form.controller', () => { responseMode: FormResponseMode.Encrypt, publicKey: 'some public key', title: 'some form title', + emails: [], } const MOCK_REQ = expressHandler.mockRequest({ session: { @@ -3007,7 +3003,9 @@ describe('admin-form.controller', () => { MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( okAsync(MOCK_FORM as IPopulatedForm), ) - MockAdminFormService.archiveForm.mockReturnValueOnce(okAsync(true)) + MockAdminFormService.archiveForm.mockReturnValueOnce( + okAsync(true as unknown as IFormSchema), + ) MockWorkspaceService.removeFormsFromAllWorkspaces.mockReturnValueOnce( okAsync(true), ) @@ -10671,650 +10669,4 @@ describe('admin-form.controller', () => { expect(MockAdminFormService.getFormField).not.toHaveBeenCalled() }) }) - - describe('handleGetFreeSmsCountForFormAdmin', () => { - const mockForm = { - admin: new ObjectId().toHexString(), - } as unknown as IFormSchema - const VERIFICATION_SMS_COUNT = 3 - - beforeAll(() => { - MockFormService.retrieveFormById.mockReturnValue(okAsync(mockForm)) - MockSmsService.retrieveFreeSmsCounts.mockReturnValue( - okAsync(VERIFICATION_SMS_COUNT), - ) - }) - - it('should retrieve sms counts and quota when the user and the form exist', async () => { - // Arrange - const MOCK_REQ = expressHandler.mockRequest({ - params: { - formId: mockForm._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - const mockRes = expressHandler.mockResponse() - const expected = { - freeSmsCounts: VERIFICATION_SMS_COUNT, - quota: smsConfig.smsVerificationLimit, - } - - // Act - await AdminFormController.handleGetFreeSmsCountForFormAdmin( - MOCK_REQ, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expected) - }) - - it('should return 404 when the form is not found in the database', async () => { - // Arrange - const MOCK_REQ = expressHandler.mockRequest({ - params: { - formId: new ObjectId().toHexString(), - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - MockFormService.retrieveFormById.mockReturnValueOnce( - errAsync(new FormNotFoundError()), - ) - const mockRes = expressHandler.mockResponse() - const expected = { - message: 'Form not found', - } - - // Act - await AdminFormController.handleGetFreeSmsCountForFormAdmin( - MOCK_REQ, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(404) - expect(mockRes.json).toHaveBeenCalledWith(expected) - }) - - it('should return 500 when a database error occurs during form retrieval', async () => { - // Arrange - const MOCK_REQ = expressHandler.mockRequest({ - params: { - formId: mockForm._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - const mockRes = expressHandler.mockResponse() - const retrieveSpy = jest.spyOn(FormService, 'retrieveFormById') - retrieveSpy.mockReturnValueOnce(errAsync(new DatabaseError())) - const expected = { - message: 'Something went wrong. Please try again.', - } - - // Act - await AdminFormController.handleGetFreeSmsCountForFormAdmin( - MOCK_REQ, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(500) - expect(mockRes.json).toHaveBeenCalledWith(expected) - }) - - it('should return 500 when a database error occurs during count retrieval', async () => { - // Arrange - const MOCK_REQ = expressHandler.mockRequest({ - params: { - formId: mockForm._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - const mockRes = expressHandler.mockResponse() - const retrieveSpy = jest.spyOn(SmsService, 'retrieveFreeSmsCounts') - retrieveSpy.mockReturnValueOnce(errAsync(new DatabaseError())) - const expected = { - message: 'Something went wrong. Please try again.', - } - - // Act - await AdminFormController.handleGetFreeSmsCountForFormAdmin( - MOCK_REQ, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(500) - expect(mockRes.json).toHaveBeenCalledWith(expected) - }) - }) - - describe('updateTwilioCredentials', () => { - const MOCK_USER_ID = new ObjectId().toHexString() - const MOCK_FORM_ID = new ObjectId().toHexString() - const MOCK_USER = { - _id: MOCK_USER_ID, - email: 'somerandom@example.com', - } as IPopulatedUser - const MOCK_FORM = { - admin: MOCK_USER, - _id: MOCK_FORM_ID, - title: 'mock title', - } as IPopulatedForm - - const MOCK_FORM_WITH_MSG_SRVC_NAME = { - admin: MOCK_USER, - _id: MOCK_FORM_ID, - msgSrvcName: '123', - title: 'mock title', - } as IPopulatedForm - - const MOCK_ACCOUNT_SID = 'AC12345678' - const MOCK_API_KEY_SID = 'SK12345678' - const MOCK_API_KEY_SECRET = 'AZ12345678' - const MOCK_MESSAGING_SERVICE_SID = 'MG12345678' - - const MOCK_TWILIO_CREDENTIALS: TwilioCredentials = { - accountSid: MOCK_ACCOUNT_SID, - apiKey: MOCK_API_KEY_SID, - apiSecret: MOCK_API_KEY_SECRET, - messagingServiceSid: MOCK_MESSAGING_SERVICE_SID, - } - - const createTwilioSpy = jest.spyOn( - MockAdminFormService, - 'createTwilioCredentials', - ) - const updateTwilioSpy = jest.spyOn( - MockAdminFormService, - 'updateTwilioCredentials', - ) - - it('should return 200 after the twilio credentials are successfully created', async () => { - // Arrange - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - MockAuthService.getFormAfterPermissionChecks.mockReturnValue( - okAsync(MOCK_FORM), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - body: MOCK_TWILIO_CREDENTIALS, - }) - - // Returns empty response because mongo transaction returns Promise - createTwilioSpy.mockReturnValueOnce(okAsync(null)) - - const mockRes = expressHandler.mockResponse() - - // Act - await AdminFormController.updateTwilioCredentials( - mockReq, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith({ - message: 'Successfully updated Twilio credentials', - }) - expect(createTwilioSpy).toHaveBeenCalledTimes(1) - expect(updateTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 200 after the twilio credentials are successfully updated', async () => { - // Arrange - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - MockAuthService.getFormAfterPermissionChecks.mockReturnValue( - okAsync(MOCK_FORM_WITH_MSG_SRVC_NAME), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - body: MOCK_TWILIO_CREDENTIALS, - }) - - updateTwilioSpy.mockReturnValueOnce(okAsync(1)) - - const mockRes = expressHandler.mockResponse() - - // Act - await AdminFormController.updateTwilioCredentials( - mockReq, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith({ - message: 'Successfully updated Twilio credentials', - }) - expect(updateTwilioSpy).toHaveBeenCalledTimes(1) - expect(createTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 403 when current user does not have permissions to update form', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - - const expectedErrorString = 'no write permissions' - MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( - errAsync(new ForbiddenFormError(expectedErrorString)), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - body: MOCK_TWILIO_CREDENTIALS, - }) - - // Act - await AdminFormController.updateTwilioCredentials( - mockReq, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(403) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(updateTwilioSpy).not.toHaveBeenCalled() - expect(createTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 404 when form to update cannot be found', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - - const expectedErrorString = 'Form not found' - MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( - errAsync(new FormNotFoundError(expectedErrorString)), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - body: MOCK_TWILIO_CREDENTIALS, - }) - - // Act - await AdminFormController.updateTwilioCredentials( - mockReq, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(404) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(updateTwilioSpy).not.toHaveBeenCalled() - expect(createTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 422 on MissingUserError', async () => { - // Arrange - const errorMessage = 'User not found' - MockUserService.getPopulatedUserById.mockReturnValueOnce( - errAsync(new MissingUserError(errorMessage)), - ) - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - body: MOCK_TWILIO_CREDENTIALS, - }) - - const expectedResponse = { message: errorMessage } - const mockRes = expressHandler.mockResponse() - - // Act - await AdminFormController.updateTwilioCredentials( - mockReq, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(422) - expect(mockRes.json).toHaveBeenCalledWith(expectedResponse) - expect(updateTwilioSpy).not.toHaveBeenCalled() - expect(createTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 500 when generic database error occurs during form field retrieval', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - const expectedErrorString = 'A Database error occured!' - MockUserService.getPopulatedUserById.mockReturnValueOnce( - errAsync(new DatabaseError(expectedErrorString)), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - body: MOCK_TWILIO_CREDENTIALS, - }) - - // Act - await AdminFormController.updateTwilioCredentials( - mockReq, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(500) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(updateTwilioSpy).not.toHaveBeenCalled() - expect(createTwilioSpy).not.toHaveBeenCalled() - }) - }) - - describe('handleDeleteTwilio', () => { - const MOCK_USER_ID = new ObjectId().toHexString() - const MOCK_FORM_ID = new ObjectId().toHexString() - const MOCK_USER = { - _id: MOCK_USER_ID, - email: 'somerandom@example.com', - } as IPopulatedUser - const MOCK_FORM = { - admin: MOCK_USER, - _id: MOCK_FORM_ID, - title: 'mock title', - } as IPopulatedForm - - const MOCK_FORM_WITH_CREDENTIALS = { - admin: MOCK_USER, - _id: MOCK_FORM_ID, - msgSrvcName: '123', - title: 'mock title', - } as IPopulatedForm - - const deleteTwilioSpy = jest.spyOn( - MockAdminFormService, - 'deleteTwilioCredentials', - ) - - it('should return 200 if twilio credentials are successfully deleted', async () => { - // Arrange - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - MockAuthService.getFormAfterPermissionChecks.mockReturnValue( - okAsync(MOCK_FORM_WITH_CREDENTIALS), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_WITH_CREDENTIALS._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - - // Returns empty response because mongo transaction returns Promise - deleteTwilioSpy.mockReturnValueOnce(okAsync(1)) - - const mockRes = expressHandler.mockResponse() - const expected = { - message: 'Successfully deleted Twilio credentials', - } - - // Act - await AdminFormController.handleDeleteTwilio(mockReq, mockRes, jest.fn()) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expected) - expect(deleteTwilioSpy).toHaveBeenCalledTimes(1) - }) - - it('should return 200 if no twilio credentials need to be deleted', async () => { - // Arrange - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - MockAuthService.getFormAfterPermissionChecks.mockReturnValue( - okAsync(MOCK_FORM), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - - // Returns empty response because mongo transaction returns Promise - deleteTwilioSpy.mockReturnValueOnce(okAsync(null)) - - const mockRes = expressHandler.mockResponse() - const expected = { - message: 'Successfully deleted Twilio credentials', - } - - // Act - await AdminFormController.handleDeleteTwilio(mockReq, mockRes, jest.fn()) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expected) - expect(deleteTwilioSpy).toHaveBeenCalled() - }) - - it('should return 403 when current user does not have permissions to update form', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - - const expectedErrorString = 'no write permissions' - MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( - errAsync(new ForbiddenFormError(expectedErrorString)), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_WITH_CREDENTIALS._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - - // Act - await AdminFormController.handleDeleteTwilio(mockReq, mockRes, jest.fn()) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(403) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(deleteTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 404 when form to update cannot be found', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - - const expectedErrorString = 'Form not found' - MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( - errAsync(new FormNotFoundError(expectedErrorString)), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_WITH_CREDENTIALS._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - - // Act - await AdminFormController.handleDeleteTwilio(mockReq, mockRes, jest.fn()) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(404) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(deleteTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 422 on MissingUserError', async () => { - // Arrange - const errorMessage = 'User not found' - MockUserService.getPopulatedUserById.mockReturnValueOnce( - errAsync(new MissingUserError(errorMessage)), - ) - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_WITH_CREDENTIALS._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - - const mockRes = expressHandler.mockResponse() - - // Act - await AdminFormController.handleDeleteTwilio(mockReq, mockRes, jest.fn()) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(422) - expect(mockRes.json).toHaveBeenCalledWith({ message: 'User not found' }) - expect(deleteTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 500 when generic database error occurs during form field retrieval', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - const expectedErrorString = 'A Database error occured!' - MockUserService.getPopulatedUserById.mockReturnValueOnce( - errAsync(new DatabaseError(expectedErrorString)), - ) - - const MOCK_REQ = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_WITH_CREDENTIALS._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - - // Act - await AdminFormController.handleDeleteTwilio(MOCK_REQ, mockRes, jest.fn()) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(500) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(deleteTwilioSpy).not.toHaveBeenCalled() - }) - }) }) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts index 77a3c609b8..22c7f37093 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts @@ -11,7 +11,7 @@ import { EncryptedStringsMessageContentWithMyPrivateKey, } from 'shared/utils/crypto' -import config, { aws } from 'src/app/config/config' +import { aws } from 'src/app/config/config' import getAgencyModel from 'src/app/models/agency.server.model' import getFormModel, { getEmailFormModel, @@ -29,7 +29,6 @@ import { } from 'src/app/modules/core/core.errors' import { MissingUserError } from 'src/app/modules/user/user.errors' import * as UserService from 'src/app/modules/user/user.service' -import { TwilioCredentials } from 'src/app/services/sms/sms.types' import { CreatePresignedPostError } from 'src/app/utils/aws-s3' import { formatErrorRecoveryMessage } from 'src/app/utils/handle-mongo-error' import { EditFieldActions } from 'src/shared/constants' @@ -75,7 +74,6 @@ import { PaymentType, SettingsUpdateDto, } from '../../../../../../shared/types' -import * as SmsService from '../../../../services/sms/sms.service' import { FormNotFoundError, LogicNotFoundError, @@ -91,8 +89,6 @@ import * as AdminFormService from '../admin-form.service' import { OverrideProps } from '../admin-form.types' import * as AdminFormUtils from '../admin-form.utils' -import { secretsManager } from './../admin-form.service' - const FormModel = getFormModel(mongoose) const EmailFormModel = getEmailFormModel(mongoose) const EncryptFormModel = getEncryptedFormModel(mongoose) @@ -104,9 +100,6 @@ const FormWhitelistedSubmitterIdsModel = jest.mock('src/app/modules/user/user.service') const MockUserService = jest.mocked(UserService) -jest.mock('../../../../services/sms/sms.service') -const MockSmsService = jest.mocked(SmsService) - describe('admin-form.service', () => { beforeEach(async () => { jest.clearAllMocks() @@ -2737,163 +2730,6 @@ describe('admin-form.service', () => { }) }) - describe('createTwilioCredentials', () => { - const MOCK_FORM_ID = new mongoose.Types.ObjectId() - const MOCK_ADMIN_ID = new mongoose.Types.ObjectId() - - const MOCK_FORM = { - _id: MOCK_FORM_ID, - admin: { - _id: MOCK_ADMIN_ID, - }, - } as unknown as IPopulatedForm - - const MOCK_ACCOUNT_SID = 'AC12345678' - const MOCK_API_KEY_SID = 'SK12345678' - const MOCK_API_KEY_SECRET = 'AZ12345678' - const MOCK_MESSAGING_SERVICE_SID = 'MG12345678' - - const TWILIO_CREDENTIALS: TwilioCredentials = { - accountSid: MOCK_ACCOUNT_SID, - apiKey: MOCK_API_KEY_SID, - apiSecret: MOCK_API_KEY_SECRET, - messagingServiceSid: MOCK_MESSAGING_SERVICE_SID, - } - - const sessionSpy = jest.spyOn(FormModel, 'startSession') - - it('should return undefined when Twilio credentials was created successfully', async () => { - // Arrange - sessionSpy.mockResolvedValueOnce({ - withTransaction: () => { - return { - then: () => undefined, - } - }, - } as any) - - // Act - const actualResult = await AdminFormService.createTwilioCredentials( - TWILIO_CREDENTIALS, - MOCK_FORM, - ) - - // Assert - expect(actualResult.isOk()).toEqual(true) - expect(actualResult._unsafeUnwrap()).toEqual(undefined) - - expect(sessionSpy).toHaveBeenCalled() - }) - }) - - describe('updateTwilioCredentials', () => { - const MOCK_FORM_ID = new mongoose.Types.ObjectId() - - const MOCK_ACCOUNT_SID = 'AC12345678' - const MOCK_API_KEY_SID = 'SK12345678' - const MOCK_API_KEY_SECRET = 'AZ12345678' - const MOCK_MESSAGING_SERVICE_SID = 'MG12345678' - - const TWILIO_CREDENTIALS: TwilioCredentials = { - accountSid: MOCK_ACCOUNT_SID, - apiKey: MOCK_API_KEY_SID, - apiSecret: MOCK_API_KEY_SECRET, - messagingServiceSid: MOCK_MESSAGING_SERVICE_SID, - } - - it('should return the response of performing PutSecretValue operation on the SecretsManager', async () => { - // Arrange - const msgSrvcName = `formsg/${config.secretEnv}/form/${MOCK_FORM_ID}/twilio` - - const getSecretsSpy = jest - .spyOn(secretsManager, 'getSecretValue') - .mockImplementationOnce(() => { - return { - promise: () => { - return Promise.resolve({ - Name: msgSrvcName, - }) - }, - } as any - }) - - const twilioCacheSpy = jest - .spyOn(MockSmsService.twilioClientCache, 'del') - .mockReturnValueOnce(1) - - const putSecretsSpy = jest - .spyOn(secretsManager, 'putSecretValue') - .mockImplementationOnce(() => { - return { - promise: () => { - return Promise.resolve({ - Name: msgSrvcName, - }) - }, - } as any - }) - - // Act - - const actualResult = await AdminFormService.updateTwilioCredentials( - msgSrvcName, - TWILIO_CREDENTIALS, - ) - - // Assert - expect(actualResult.isOk()).toEqual(true) - expect(actualResult._unsafeUnwrap()).toEqual(1) - - expect(getSecretsSpy).toHaveBeenCalledWith({ - SecretId: msgSrvcName, - }) - expect(twilioCacheSpy).toHaveBeenCalledWith(msgSrvcName) - expect(putSecretsSpy).toHaveBeenCalledWith({ - SecretId: msgSrvcName, - SecretString: JSON.stringify(TWILIO_CREDENTIALS), - }) - }) - }) - - describe('deleteTwilioCredentials', () => { - const MOCK_FORM_ID = new mongoose.Types.ObjectId() - const sessionSpy = jest.spyOn(FormModel, 'startSession') - const MSG_SRVC_NAME = `formsg/${config.secretEnv}/form/${MOCK_FORM_ID}/twilio` - const MOCK_FORM = { - _id: MOCK_FORM_ID, - save: () => MOCK_FORM, - msgSrvcName: MSG_SRVC_NAME, - } as unknown as IPopulatedForm - - it('should return result of clearing TwilioCache entry when Twilio credentials was successfully deleted', async () => { - // Arrange - sessionSpy.mockResolvedValueOnce({ - withTransaction: () => { - return { - then: () => undefined, - } - }, - } as any) - - // formSpy.mockResolvedValueOnce(MOCK_FORM) - - const twilioCacheSpy = jest - .spyOn(MockSmsService.twilioClientCache, 'del') - .mockReturnValueOnce(1) - - // Act - - const actualResult = - await AdminFormService.deleteTwilioCredentials(MOCK_FORM) - - // Assert - expect(actualResult.isOk()).toEqual(true) - expect(actualResult._unsafeUnwrap()).toEqual(1) - - expect(twilioCacheSpy).toHaveBeenCalledWith(MSG_SRVC_NAME) - }) - }) - describe('checkIsWhitelistSettingValid', () => { const MOCK_VALID_UEN = '53244311W' const MOCK_VALID_FIN = 'F1612366T' diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index 53268049e3..7f5e87e2ac 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -43,7 +43,6 @@ import { PrivateFormErrorDto, PublicFormDto, SettingsUpdateDto, - SmsCountsDto, StartPageUpdateDto, SubmissionCountQueryDto, WebhookSettingsUpdateDto, @@ -59,10 +58,8 @@ import { ParsedEmailModeSubmissionBody, } from '../../../../types/api' import { goGovConfig } from '../../../config/features/gogov.config' -import { smsConfig } from '../../../config/features/sms.config' import { createLoggerWithLabel } from '../../../config/logger' import MailService from '../../../services/mail/mail.service' -import * as SmsService from '../../../services/sms/sms.service' import { createReqMeta } from '../../../utils/request' import * as AuthService from '../../auth/auth.service' import { @@ -93,7 +90,6 @@ import { removeFormsFromAllWorkspaces } from '../../workspace/workspace.service' import { PrivateFormError } from '../form.errors' import * as FormService from '../form.service' -import { TwilioCredentials } from './../../../services/sms/sms.types' import { PREVIEW_CORPPASS_UID, PREVIEW_CORPPASS_UINFIN, @@ -3148,183 +3144,6 @@ export const handleUpdateStartPage = [ _handleUpdateStartPage, ] as ControllerHandler[] -/** - * Handler to retrieve the free sms counts used by a form's administrator and the sms verifications quota - * This is the controller for GET /admin/forms/:formId/verified-sms/count/free - * @param formId The id of the form to retrieve the free sms counts for - * @returns 200 with free sms counts and quota when successful - * @returns 404 when the formId is not found in the database - * @returns 500 when a database error occurs during retrieval - */ -export const handleGetFreeSmsCountForFormAdmin: ControllerHandler< - { - formId: string - }, - ErrorDto | SmsCountsDto -> = (req, res) => { - const { formId } = req.params - const logMeta = { - action: 'handleGetFreeSmsCountForFormAdmin', - ...createReqMeta(req), - formId, - } - - // Step 1: Check that the form exists - return ( - FormService.retrieveFormById(formId) - // Step 2: Retrieve the free sms count - .andThen(({ admin }) => { - return SmsService.retrieveFreeSmsCounts(String(admin)) - }) - // Step 3: Map/MapErr accordingly - .map((freeSmsCountForAdmin) => - res.status(StatusCodes.OK).json({ - freeSmsCounts: freeSmsCountForAdmin, - quota: smsConfig.smsVerificationLimit, - }), - ) - .mapErr((error) => { - logger.error({ - message: 'Error while retrieving sms counts for user', - meta: logMeta, - error, - }) - const { statusCode, errorMessage } = mapRouteError(error) - return res.status(statusCode).json({ message: errorMessage }) - }) - ) -} - -// Validates Twilio Credentials -const validateTwilioCredentials = celebrate({ - [Segments.BODY]: Joi.object().keys({ - accountSid: Joi.string().required().pattern(new RegExp('^AC')), - apiKey: Joi.string().required().pattern(new RegExp('^SK')), - apiSecret: Joi.string().required(), - messagingServiceSid: Joi.string().required().pattern(new RegExp('^MG')), - }), -}) -/** - * Handler for PUT /:formId/twilio. - * @security session - * - * @returns 200 with twilio credentials succesfully updated - * @returns 400 with twilio credentials are invalid - * @returns 401 when user is not logged in - * @returns 403 when user does not have permissions to update the form - * @returns 404 when form to update cannot be found - * @returns 422 when id of user who is updating the form cannot be found - * @returns 500 when database error occurs - */ -export const updateTwilioCredentials: ControllerHandler< - { formId: string }, - unknown, - TwilioCredentials -> = (req, res) => { - const { formId } = req.params - const twilioCredentials = req.body - - const sessionUserId = (req.session as AuthedSessionData).user._id - - return UserService.getPopulatedUserById(sessionUserId) - .andThen((user) => - AuthService.getFormAfterPermissionChecks({ - user, - formId, - level: PermissionLevel.Write, - }), - ) - .andThen((retrievedForm) => { - const { msgSrvcName } = retrievedForm - - return msgSrvcName - ? AdminFormService.updateTwilioCredentials( - msgSrvcName, - twilioCredentials, - ) - : AdminFormService.createTwilioCredentials( - twilioCredentials, - retrievedForm, - ) - }) - .map(() => - res - .status(StatusCodes.OK) - .json({ message: 'Successfully updated Twilio credentials' }), - ) - .mapErr((error) => { - logger.error({ - message: 'Error occurred when updating twilio credentials', - meta: { - action: 'handleUpdateTwilio', - ...createReqMeta(req), - userId: sessionUserId, - formId, - twilioCredentials, - }, - error, - }) - const { errorMessage, statusCode } = mapRouteError(error) - return res.status(statusCode).json({ message: errorMessage }) - }) -} - -/** - * Handler for DELETE /:formId/twilio. - * @security session - * - * @returns 200 with twilio credentials succesfully updated - * @returns 401 when user is not logged in - * @returns 403 when user does not have permissions to update the form - * @returns 404 when form to delete credentials cannot be found - * @returns 422 when id of user who is updating the form cannot be found - * @returns 500 when database error occurs - */ -export const handleDeleteTwilio: ControllerHandler<{ formId: string }> = ( - req, - res, -) => { - const { formId } = req.params - const sessionUserId = (req.session as AuthedSessionData).user._id - - return UserService.getPopulatedUserById(sessionUserId) - .andThen((user) => - AuthService.getFormAfterPermissionChecks({ - user, - formId, - level: PermissionLevel.Delete, - }), - ) - .andThen((retrievedForm) => { - return AdminFormService.deleteTwilioCredentials(retrievedForm) - }) - .map(() => - res - .status(StatusCodes.OK) - .json({ message: 'Successfully deleted Twilio credentials' }), - ) - .mapErr((error) => { - logger.error({ - message: 'Error occurred when deleting twilio credentials', - meta: { - action: 'handleDeleteTwilio', - ...createReqMeta(req), - userId: sessionUserId, - formId, - }, - error, - }) - const { errorMessage, statusCode } = mapRouteError(error) - return res.status(statusCode).json({ message: errorMessage }) - }) -} - -// Handler for PUT /admin/forms/:formId/twilio -export const handleUpdateTwilio = [ - validateTwilioCredentials, - updateTwilioCredentials, -] as ControllerHandler[] - export const handleGetGoLinkSuffix: ControllerHandler<{ formId: string }> = ( req, res, diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts index 49cb88e44e..74545217c0 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -1,10 +1,4 @@ -import { AWSError, SecretsManager } from 'aws-sdk' import { PresignedPost } from 'aws-sdk/clients/s3' -import { - CreateSecretRequest, - DeleteSecretRequest, - PutSecretValueRequest, -} from 'aws-sdk/clients/secretsmanager' import { assignIn, last, omit, pick } from 'lodash' import mongoose, { ClientSession } from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' @@ -73,13 +67,12 @@ import { IPopulatedUser, } from '../../../../types' import { EditFormFieldParams, FormUpdateParams } from '../../../../types/api' -import config, { aws as AwsConfig } from '../../../config/config' +import { aws as AwsConfig } from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import getAgencyModel from '../../../models/agency.server.model' import getFormModel from '../../../models/form.server.model' import getFormWhitelistSubmitterIdsModel from '../../../models/form_whitelist.server.model' import { getWorkspaceModel } from '../../../models/workspace.server.model' -import { twilioClientCache } from '../../../services/sms/sms.service' import { createPresignedPostDataPromise, CreatePresignedPostError, @@ -96,9 +89,6 @@ import { DatabaseValidationError, MalformedParametersError, PossibleDatabaseError, - SecretsManagerError, - SecretsManagerNotFoundError, - TwilioCacheError, } from '../../core/core.errors' import { MissingUserError } from '../../user/user.errors' import * as UserService from '../../user/user.service' @@ -118,10 +108,6 @@ import { isFormEncryptMode, } from '../form.utils' -import { - TwilioCredentials, - TwilioCredentialsData, -} from './../../../services/sms/sms.types' import { PRESIGNED_POST_EXPIRY_SECS } from './admin-form.constants' import { EditFieldError, @@ -130,8 +116,6 @@ import { InvalidFileTypeError, } from './admin-form.errors' import { - checkIsApiSecretKeyName, - generateTwilioCredSecretKeyName, getUpdatedFormFields, insertTableShortTextColumnDefaultValidationOptions, processDuplicateOverrideProps, @@ -144,11 +128,6 @@ const WorkspaceModel = getWorkspaceModel(mongoose) const FormWhitelistedSubmitterIdsModel = getFormWhitelistSubmitterIdsModel(mongoose) -export const secretsManager = new SecretsManager({ - region: config.aws.region, - endpoint: process.env.AWS_ENDPOINT, -}) - type PresignedPostUrlParams = { fileId: string fileMd5Hash: string @@ -2273,318 +2252,6 @@ export const updateStartPage = ( }) } -/** - * Creates msgSrvcName and updates the form in MongoDB as part of a transaction, uses the created - * msgSrvcName as the key to store the Twilio Credentials in AWS Secrets Manager - * @param twilioCredentials The twilio credentials to add - * @param form The form to add Twilio Credentials - * @returns ok(undefined) if the creation is successful - * @returns err(SecretsManagerError) if an error occurs while creating credentials in secrets manager - */ -export const createTwilioCredentials = ( - twilioCredentials: TwilioCredentials, - form: IPopulatedForm, -): ResultAsync< - unknown, - ReturnType | SecretsManagerError -> => { - const twilioCredentialsData: TwilioCredentialsData = - new TwilioCredentialsData(twilioCredentials) - const formId = form._id - - const msgSrvcName = generateTwilioCredSecretKeyName(formId) - - const body: CreateSecretRequest = { - Name: msgSrvcName, - SecretString: twilioCredentialsData.toString(), - Description: `autogenerated via API on ${new Date().toISOString()} by ${ - form.admin._id - }`, - } - - const logMeta = { - action: 'createTwilioCredentials', - formId: formId, - msgSrvcName, - body, - } - - logger.info({ - message: `No msgSrvcName, creating Twilio credentials for form ${formId}`, - meta: logMeta, - }) - - return ResultAsync.fromPromise( - FormModel.startSession().then((session: ClientSession) => - session - .withTransaction(() => - createTwilioTransaction(form, msgSrvcName, body, session), - ) - .then(() => session.endSession()), - ), - (error) => { - logger.error({ - message: 'Error encountered when creating Twilio Secret', - meta: logMeta, - error, - }) - - return error as - | ReturnType - | SecretsManagerError - }, - ) -} - -/** - * Updates msgSrvcName of the form in the database, uses the msgSrvcName as the - * key to store the Twilio Credentials in AWS Secrets Manager - * @param form The form to add Twilio Credentials - * @param msgSrvcName The key under which the credentials is stored in AWS Secrets Manager - * @param body the request body used to create the secret in secrets manager - * @param session session of the transaction - * @returns Promise.ok(void) if the creation is successful - */ -// Exported to use in tests -export const createTwilioTransaction = async ( - form: IPopulatedForm, - msgSrvcName: string, - body: CreateSecretRequest, - session: ClientSession, -): Promise => { - const meta = { - action: 'createTwilioTransaction', - formId: form._id, - msgSrvcName, - body, - } - - try { - await form.updateMsgSrvcName(msgSrvcName, session) - } catch (err) { - logger.error({ - message: - 'Error occured when updating msgSrvcName, rolling back transaction!', - meta, - error: err, - }) - throw transformMongoError(err) - } - - try { - await secretsManager.createSecret(body).promise() - } catch (err) { - const awsError = err as AWSError - - logger.error({ - message: - 'Error occured when creating secret AWS Secrets Manager, rolling back transaction!', - meta, - error: awsError, - }) - throw new SecretsManagerError(awsError.message) - } -} - -/** - * Uses the msgSrvcName to update the Twilio Credentials in AWS Secrets Manager - * Clears the cache entry in which the Twilio Credentials are stored under - * @param twilioCredentials The twilio credentials to add - * @param msgSrvcName The key under which the credentials are stored in Secrets Manager - * @returns ok(number) if the update is successful - * @returns err(SecretsManagerNotFoundError) if there is no secret stored under msgSrvcName in secrets manager - * @returns err(SecretsManagerError) if an error occurs while updating credentials in secrets manager - */ -export const updateTwilioCredentials = ( - msgSrvcName: string, - twilioCredentials: TwilioCredentials, -): ResultAsync< - number, - SecretsManagerError | SecretsManagerNotFoundError | TwilioCacheError -> => { - const twilioCredentialsData: TwilioCredentialsData = - new TwilioCredentialsData(twilioCredentials) - - const body: PutSecretValueRequest = { - SecretId: msgSrvcName, - SecretString: twilioCredentialsData.toString(), - } - - const logMeta = { - action: 'updateTwilioCredentials', - msgSrvcName, - body, - } - - return ( - ResultAsync.fromPromise( - secretsManager.getSecretValue({ SecretId: msgSrvcName }).promise(), - (error) => { - const awsError = error as AWSError - - if (awsError.code === 'ResourceNotFoundException') { - logger.error({ - message: 'Twilio Credentials do not exist in Secrets Manager', - meta: logMeta, - error, - }) - - return new SecretsManagerNotFoundError(awsError.message) - } - - logger.error({ - message: 'Error occurred when retrieving Twilio in Secret Manager!', - meta: { - ...logMeta, - body, - }, - error, - }) - - return new SecretsManagerError(awsError.message) - }, - ) - .andThen(() => { - logger.info({ - message: 'Twilio Credentials has been found in Secrets Manager', - meta: logMeta, - }) - - return ResultAsync.fromPromise( - secretsManager.putSecretValue(body).promise(), - (error) => { - logger.error({ - message: 'Error occurred when updating Twilio in Secret Manager!', - meta: { - ...logMeta, - body, - }, - error, - }) - - return new SecretsManagerError( - 'Error occurred when updating Twilio in Secret Manager!', - ) - }, - ) - }) - // Currently, a call to get twilio credentials will cache the credentials in the twilioCache for ~10s - // If a call to retrieve twilio credentials occurs before 10s passes, it will be a cache hit, retrieving - // the wrong credentials. Hence we need to clear the cache entry - .map(() => twilioClientCache.del(msgSrvcName)) - ) -} - -/** - * Uses the msgSrvcName to schedule the Twilio Credentials for deletion in AWS Secrets Manager and removes - * msgSrvcName from the form in MongoDB as part of a transaction - * - * Clears the cache entry in which the Twilio Credentials are stored under - * @param form The form to delete Twilio Credentials - * @param msgSrvcName The key under which the credentials are stored in Secrets Manager - * @returns ok(number) if the deletion is successful - * @returns err(SecretsManagerNotFoundError) if there is no secret stored under msgSrvcName in secrets manager - * @returns err(SecretsManagerError) if an error occurs while deleting credentials in secrets manager - */ -export const deleteTwilioCredentials = ( - form: IPopulatedForm, -): ResultAsync< - unknown, - | ReturnType - | SecretsManagerError - | TwilioCacheError -> => { - if (!form.msgSrvcName) return okAsync(null) - - const msgSrvcName = form.msgSrvcName - const body: DeleteSecretRequest = { - SecretId: msgSrvcName, - } - /** - * - * The key-value pair will remain in SecretsManager for another 30 days before - * being deleted: https://docs.aws.amazon.com/secretsmanager/latest/userguide/manage_delete-secret.html - * - */ - - const formId = form._id - - const logMeta = { - action: 'deleteTwilioCredentials', - formId, - msgSrvcName, - body, - } - - return ResultAsync.fromPromise( - FormModel.startSession().then((session: ClientSession) => - session - .withTransaction(() => deleteTwilioTransaction(form, body, session)) - .then(() => session.endSession()), - ), - (error) => { - logger.error({ - message: 'Error occurred when deleting Twilio in Secret Manager!', - meta: logMeta, - error, - }) - - return error as - | ReturnType - | SecretsManagerError - }, - ).map(() => twilioClientCache.del(msgSrvcName)) -} - -/** - * Deletes the msgSrvcName of the specified form in the database and uses the msgSrvcName as the - * key to delete the Twilio Credentials in AWS Secrets Manager - * @param form The form to delete Twilio Credentials - * @param msgSrvcName The key under which the credentials is stored in AWS Secrets Manager - * @param body the request body used to delete the secret in secrets manager - * @param session session of the transaction - * @returns Promise.ok(void) if the creation is successful - */ -const deleteTwilioTransaction = async ( - form: IPopulatedForm, - body: DeleteSecretRequest, - session: ClientSession, -): Promise => { - const msgSrvcName = body.SecretId - const meta = { - action: 'deleteTwilioTransaction', - formId: form._id, - msgSrvcName, - body, - } - - try { - await form.deleteMsgSrvcName(session) - } catch (err) { - logger.error({ - message: - 'Error occured when deleting msgSrvcName in MongoDB, rolling back transaction!', - meta, - error: err, - }) - throw transformMongoError(err) - } - - try { - if (checkIsApiSecretKeyName(msgSrvcName)) - await secretsManager.deleteSecret(body).promise() - } catch (err) { - const awsError = err as AWSError - logger.error({ - message: - 'Error occured when deleting secret key in AWS Secrets Manager, rolling back transaction!', - meta, - error: awsError, - }) - throw new SecretsManagerError(awsError.message) - } -} - export const archiveForms = async ({ formIds, session, diff --git a/src/app/modules/form/admin-form/admin-form.utils.ts b/src/app/modules/form/admin-form/admin-form.utils.ts index 3e0850bd3d..bced735815 100644 --- a/src/app/modules/form/admin-form/admin-form.utils.ts +++ b/src/app/modules/form/admin-form/admin-form.utils.ts @@ -2,7 +2,6 @@ import { AxiosError } from 'axios' import { type Joi } from 'celebrate' import { StatusCodes } from 'http-status-codes' import { err, ok, Result } from 'neverthrow' -import { v4 as uuidv4 } from 'uuid' import { BasicField, @@ -19,7 +18,6 @@ import { import { EditFieldActions } from '../../../../shared/constants' import { FormFieldSchema, IPopulatedForm, IUserSchema } from '../../../../types' import { EditFormFieldParams } from '../../../../types/api' -import config from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import { CreatePresignedPostError } from '../../../utils/aws-s3' import { isPossibleEmailFieldSchema } from '../../../utils/field-validation/field-validation.guards' @@ -30,10 +28,6 @@ import { DatabasePayloadSizeError, DatabaseValidationError, MalformedParametersError, - SecretsManagerConflictError, - SecretsManagerError, - SecretsManagerNotFoundError, - TwilioCacheError, } from '../../core/core.errors' import { ErrorResponseData } from '../../core/core.types' import { InvalidPaymentAmountError } from '../../payments/payments.errors' @@ -157,26 +151,6 @@ export const mapRouteError = ( statusCode: StatusCodes.INTERNAL_SERVER_ERROR, errorMessage: coreErrorMessage ?? error.message, } - case SecretsManagerNotFoundError: - return { - statusCode: StatusCodes.NOT_FOUND, - errorMessage: coreErrorMessage ?? error.message, - } - case SecretsManagerConflictError: - return { - statusCode: StatusCodes.CONFLICT, - errorMessage: coreErrorMessage ?? error.message, - } - case SecretsManagerError: - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorMessage: coreErrorMessage ?? error.message, - } - case TwilioCacheError: - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorMessage: coreErrorMessage ?? error.message, - } case StripeAccountError: return { statusCode: StatusCodes.BAD_GATEWAY, @@ -516,28 +490,6 @@ export const getUpdatedFormFields = ( } } -/** - * Returns a msgSrvcName that will be used as the key to store secrets - * under AWS Secrets Manager - * - * @param formId in which the secrets belong to - * @returns string representing the msgSrvcName - */ -export const generateTwilioCredSecretKeyName = (formId: string): string => - `formsg/${config.secretEnv}/api/form/${formId}/twilio/${uuidv4()}` - -/** - * Returns boolean indicating if the key to store the secret in AWS Secrets Manager - * was generated by the API - * - * @param msgSrvcName to check - * @returns boolean indicating whether it was generated by the API - */ -export const checkIsApiSecretKeyName = (msgSrvcName: string): boolean => { - const prefix = `formsg/${config.secretEnv}/api` - return msgSrvcName.startsWith(prefix) -} - /** * Validation check for invalid utf-8 encoded unicode-escaped characteres * @param value diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index d0968525e6..67461ca653 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -16,7 +16,6 @@ import { encryptString } from '../../../../shared/utils/crypto' import { IEmailFormModel, IEncryptedFormModel, - IFormDocument, IFormSchema, IMultirespondentFormModel, IPopulatedForm, @@ -449,41 +448,6 @@ export const checkIsIntranetFormAccess = ( return isIntranetUser } -export const retrievePublicFormsWithSmsVerification = ( - userId: string, -): ResultAsync => { - return ResultAsync.fromPromise( - FormModel.retrievePublicFormsWithSmsVerification(userId), - (error) => { - logger.error({ - message: 'Error retrieving public forms with sms verifications', - meta: { - action: 'retrievePublicFormsWithSmsVerification', - userId: userId, - }, - error, - }) - - return transformMongoError(error) - }, - ).andThen((forms) => { - if (!forms.length) { - // NOTE: Warn here because this is supposed to be called to generate a list of form titles - // When the admin has used up their sms verification limit. - // It is not an error because there are potential cases where the admins privatize their form after. - logger.warn({ - message: - 'Attempted to retrieve public forms with sms verifications but none was found', - meta: { - action: 'retrievePublicFormsWithSmsVerification', - userId: userId, - }, - }) - } - return okAsync(forms) - }) -} - export const createSingleSampleSubmissionAnswer = (field: FormFieldDto) => { // Prefill dropdown MyInfo field options for faking const { fieldType } = field diff --git a/src/app/modules/form/form.utils.ts b/src/app/modules/form/form.utils.ts index 1d0a2954e4..0db65dfb0d 100644 --- a/src/app/modules/form/form.utils.ts +++ b/src/app/modules/form/form.utils.ts @@ -1,19 +1,14 @@ import { FormPermission, FormResponseMode } from '../../../../shared/types' import { FormFieldSchema, - FormLinkView, FormLogicSchema, IEncryptedFormSchema, - IForm, - IFormDocument, IFormHasEmailSchema, IFormSchema, IMultirespondentFormSchema, - IOnboardedForm, IPopulatedEmailForm, IPopulatedForm, } from '../../../types' -import { smsConfig } from '../../config/features/sms.config' import { isMongooseDocumentArray } from '../../utils/mongoose' // Converts 'test@hotmail.com, test@gmail.com' to ['test@hotmail.com', 'test@gmail.com'] @@ -180,30 +175,6 @@ export const getLogicById = ( return form_logics.find((logic) => logicId === String(logic._id)) ?? null } -/** - * Checks if a given form is onboarded (the form's message service name is defined and different from the default) - * @param form The form to check - * @returns boolean indicating if the form is/is not onboarded - */ -export const isFormOnboarded = ( - form: Pick, -): form is IOnboardedForm => { - return form.msgSrvcName - ? !(form.msgSrvcName === smsConfig.twilioMsgSrvcSid) - : false -} - -export const extractFormLinkView = ( - form: Pick, - appUrl: string, -): FormLinkView => { - const { title, _id } = form - return { - title, - link: `${appUrl}/${_id}`, - } -} - /** * Regex to to detect invalid-encoded utf-8 characters in stringified form field input * Matches any sequence which starts with a non-backslash, an odd number of backslashes, followed by unicode escape sequence diff --git a/src/app/modules/twilio/__tests__/twilio.controller.spec.ts b/src/app/modules/twilio/__tests__/twilio.controller.spec.ts deleted file mode 100644 index dd97ba058c..0000000000 --- a/src/app/modules/twilio/__tests__/twilio.controller.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* eslint-disable import/first */ -import expressHandler from '__tests__/unit/backend/helpers/jest-express' -import getMockLogger from '__tests__/unit/backend/helpers/jest-logger' - -import * as LoggerModule from 'src/app/config/logger' - -const MockLoggerModule = jest.mocked(LoggerModule) -const mockLogger = getMockLogger() - -jest.mock('src/app/config/logger') -MockLoggerModule.createLoggerWithLabel.mockReturnValue(mockLogger) - -import { twilioSmsUpdates } from 'src/app/modules/twilio/twilio.controller' -import { ITwilioSmsWebhookBody } from 'src/types' - -describe('twilio.controller', () => { - beforeEach(() => { - jest.resetAllMocks() - }) - - const MOCK_SUCCESSFUL_MESSAGE: ITwilioSmsWebhookBody = { - SmsSid: '12345', - SmsStatus: 'delivered', - MessageStatus: 'delivered', - To: '+12345678', - MessageSid: 'SM212312', - AccountSid: 'AC123456', - From: '+12345678', - ApiVersion: '2011-11-01', - } - - const MOCK_FAILED_MESSAGE: ITwilioSmsWebhookBody = { - SmsSid: '12345', - SmsStatus: 'failed', - MessageStatus: 'failed', - To: '+12345678', - MessageSid: 'SM212312', - AccountSid: 'AC123456', - From: '+12345678', - ApiVersion: '2011-11-01', - ErrorCode: 30001, - ErrorMessage: 'Twilio is down!', - } - - describe('twilioSmsUpdates', () => { - it('should return 200 when successfully delivered message is sent', async () => { - const mockReq = expressHandler.mockRequest({ - body: MOCK_SUCCESSFUL_MESSAGE, - others: { - protocol: 'https', - host: 'webhook-endpoint.gov.sg', - url: `/endpoint?${encodeURI('senderIp=200.0.0.0')}`, - originalUrl: `/endpoint?${encodeURI('senderIp=200.0.0.0')}`, - get: () => 'webhook-endpoint.gov.sg', - }, - }) - const mockRes = expressHandler.mockResponse() - await twilioSmsUpdates(mockReq, mockRes, jest.fn()) - - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Sms Delivery update', - meta: { - action: 'twilioSmsUpdates', - body: MOCK_SUCCESSFUL_MESSAGE, - senderIp: '200.0.0.0', - }, - }) - expect(mockLogger.error).not.toHaveBeenCalled() - expect(mockRes.sendStatus).toHaveBeenCalledWith(200) - }) - - it('should return 200 when failed delivered message is sent', async () => { - const mockReq = expressHandler.mockRequest({ - body: MOCK_FAILED_MESSAGE, - others: { - protocol: 'https', - host: 'webhook-endpoint.gov.sg', - url: `/endpoint?${encodeURI('senderIp=200.0.0.0')}`, - originalUrl: `/endpoint?${encodeURI('senderIp=200.0.0.0')}`, - get: () => 'webhook-endpoint.gov.sg', - }, - }) - const mockRes = expressHandler.mockResponse() - await twilioSmsUpdates(mockReq, mockRes, jest.fn()) - - expect(mockLogger.info).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith({ - message: 'Error occurred when attempting to send SMS on twillio', - meta: { - action: 'twilioSmsUpdates', - body: MOCK_FAILED_MESSAGE, - senderIp: '200.0.0.0', - }, - }) - expect(mockRes.sendStatus).toHaveBeenCalledWith(200) - }) - }) -}) diff --git a/src/app/modules/twilio/twilio.controller.ts b/src/app/modules/twilio/twilio.controller.ts deleted file mode 100644 index de5bd23340..0000000000 --- a/src/app/modules/twilio/twilio.controller.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { celebrate, Joi, Segments } from 'celebrate' -import { StatusCodes } from 'http-status-codes' - -import { ITwilioSmsWebhookBody, TwilioSmsStatsdTags } from 'src/types/twilio' - -import { createLoggerWithLabel } from '../../config/logger' -import { ControllerHandler } from '../core/core.types' - -import { twilioStatsdClient } from './twilio.statsd-client' - -const logger = createLoggerWithLabel(module) - -/** - * Middleware which validates that a request came from Twilio Webhook - * by checking the presence of X-Twilio-Sgnature in request header and - * sms delivery status request body parameters - */ -const validateTwilioWebhook = celebrate({ - [Segments.HEADERS]: Joi.object({ - 'x-twilio-signature': Joi.string().required(), - }).unknown(), - [Segments.BODY]: Joi.object() - .keys({ - SmsSid: Joi.string().required(), - SmsStatus: Joi.string().required(), - MessageStatus: Joi.string().required(), - To: Joi.string().required(), - MessageSid: Joi.string().required(), - MessagingServiceSid: Joi.string().required(), - AccountSid: Joi.string().required(), - From: Joi.string().required(), - ApiVersion: Joi.string().required(), - ErrorCode: Joi.number(), //Unable to find any official documentation stating the ErrorCode type but should be a number - ErrorMessage: Joi.string(), - }) - .unknown(), -}) - -/** - * Logs all incoming Webhook requests from Twilio in AWS - * - * @param req Express request object - * @param res - Express response object - */ -export const twilioSmsUpdates: ControllerHandler< - unknown, - never, - ITwilioSmsWebhookBody -> = async (req, res) => { - /** - * Currently, it seems like the status are provided as string values, theres - * no other documentation stating the properties and values in the Node SDK - * - * Example: https://www.twilio.com/docs/usage/webhooks/sms-webhooks. - */ - - // Extract public sender's ip address which was passed to twilio as a query param in the status callback - let senderIp = null - try { - const url = new URL( - req.protocol + '://' + req.get('host') + req.originalUrl, - ) - senderIp = url.searchParams.get('senderIp') - } catch { - logger.error({ - message: 'Error occurred when extracting senderIp', - meta: { - action: 'twilioSmsUpdates', - body: req.body, - originalUrl: req.originalUrl, - }, - }) - } - - const ddTags: TwilioSmsStatsdTags = { - // msgSrvcSid not included to limit tag cardinality (for now?) - smsstatus: req.body.SmsStatus, - errorcode: '0', - } - - if (req.body.ErrorCode || req.body.ErrorMessage) { - if (req.body.ErrorCode) { - ddTags.errorcode = `${req.body.ErrorCode}` - } - - logger.error({ - message: 'Error occurred when attempting to send SMS on twillio', - meta: { - action: 'twilioSmsUpdates', - body: req.body, - senderIp, - }, - }) - } else { - logger.info({ - message: 'Sms Delivery update', - meta: { - action: 'twilioSmsUpdates', - body: req.body, - senderIp, - }, - }) - } - - twilioStatsdClient.increment('sms.update', 1, 1, ddTags) - - return res.sendStatus(StatusCodes.OK) -} - -export const handleTwilioSmsUpdates = [validateTwilioWebhook, twilioSmsUpdates] diff --git a/src/app/modules/twilio/twilio.statsd-client.ts b/src/app/modules/twilio/twilio.statsd-client.ts deleted file mode 100644 index 011112927f..0000000000 --- a/src/app/modules/twilio/twilio.statsd-client.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { statsdClient } from '../../config/datadog-statsd-client' - -export const twilioStatsdClient = statsdClient.childClient({ - prefix: 'vendor.twilio.', -}) diff --git a/src/app/modules/verification/__tests__/verification.service.spec.ts b/src/app/modules/verification/__tests__/verification.service.spec.ts index 8a612e1a58..949fa86c67 100644 --- a/src/app/modules/verification/__tests__/verification.service.spec.ts +++ b/src/app/modules/verification/__tests__/verification.service.spec.ts @@ -32,13 +32,11 @@ import { MailSendError } from 'src/app/services/mail/mail.errors' import MailService from 'src/app/services/mail/mail.service' import { SmsSendError } from 'src/app/services/postman-sms/postman-sms.errors' import PostmanSmsService from 'src/app/services/postman-sms/postman-sms.service' -import { SmsFactory } from 'src/app/services/sms/sms.factory' import * as HashUtils from 'src/app/utils/hash' import { IFormSchema, IVerificationSchema, UpdateFieldData } from 'src/types' import { BasicField } from '../../../../../shared/types' import { DatabaseError } from '../../core/core.errors' -import * as FeatureFlagService from '../../feature-flags/feature-flags.service' import { FormNotFoundError } from '../../form/form.errors' import { FieldNotFoundInTransactionError, @@ -72,10 +70,10 @@ const VerificationModel = getVerificationModel(mongoose) // Set up mocks jest.mock('src/app/config/formsg-sdk') const MockFormsgSdk = jest.mocked(formsgSdk) -jest.mock('src/app/services/sms/sms.factory') -const MockSmsFactory = jest.mocked(SmsFactory) jest.mock('src/app/services/mail/mail.service') const MockMailService = jest.mocked(MailService) +jest.mock('src/app/services/postman-sms/postman-sms.service') +const MockPostmanSmsService = jest.mocked(PostmanSmsService) jest.mock('src/app/modules/form/form.service') const MockFormService = jest.mocked(FormService) jest.mock('src/app/utils/hash') @@ -276,7 +274,6 @@ describe('Verification service', () => { _id: mockFieldIdObj as unknown as string, }), ], - msgSrvcName: 'abc', } as unknown as IFormSchema let updateHashSpy: jest.SpyInstance< @@ -298,7 +295,7 @@ describe('Verification service', () => { } beforeEach(async () => { - MockSmsFactory.sendVerificationOtp.mockReturnValue(okAsync(true)) + MockPostmanSmsService.sendVerificationOtp.mockReturnValue(okAsync(true)) MockMailService.sendVerificationOtp.mockReturnValue(okAsync(true)) MockFormsgSdk.verification.generateSignature.mockReturnValue( MOCK_SIGNED_DATA, @@ -321,13 +318,13 @@ describe('Verification service', () => { ) // Default mock params has fieldType: 'mobile' - expect(MockSmsFactory.sendVerificationOtp).toHaveBeenCalledWith( - MOCK_LOCAL_RECIPIENT, - MOCK_OTP, - MOCK_OTP_PREFIX, - mockTransaction.formId, - MOCK_SENDER_IP, - ) + expect(MockPostmanSmsService.sendVerificationOtp).toHaveBeenCalledWith({ + formId: mockTransaction.formId, + otp: MOCK_OTP, + recipientPhoneNumber: MOCK_LOCAL_RECIPIENT, + otpPrefix: MOCK_OTP_PREFIX, + senderIp: MOCK_SENDER_IP, + }) expect( MockFormsgSdk.verification.generateSignature, ).toHaveBeenCalledWith({ @@ -339,48 +336,6 @@ describe('Verification service', () => { expect(result._unsafeUnwrap()).toEqual(mockTransactionSuccessful) }) - it('should send OTP with postman if platform has feature flag on', async () => { - jest - .spyOn(FeatureFlagService, 'getFeatureFlag') - .mockReturnValue(okAsync(true)) - - const postmanSpy = jest - .spyOn(PostmanSmsService, 'sendVerificationOtp') - .mockResolvedValueOnce(okAsync(true)) - - await VerificationService.sendNewOtp(mockSendNewFormOtpValidInput) - - // Default mock params has fieldType: 'mobile' - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() - - expect(postmanSpy).toHaveBeenCalledOnce() - }) - - it('should send OTP with twilio if platform has feature flag off', async () => { - jest - .spyOn(FeatureFlagService, 'getFeatureFlag') - .mockReturnValue(okAsync(false)) - const postmanSpy = jest - .spyOn(PostmanSmsService, 'sendVerificationOtp') - .mockResolvedValueOnce(okAsync(true)) - - await VerificationService.sendNewOtp(mockSendNewFormOtpValidInput) - - // Default mock params has fieldType: 'mobile' - expect(MockSmsFactory.sendVerificationOtp).toHaveBeenCalledWith( - MOCK_LOCAL_RECIPIENT, - MOCK_OTP, - MOCK_OTP_PREFIX, - mockTransaction.formId, - MOCK_SENDER_IP, - ) - - // Default mock params has fieldType: 'mobile' - expect(MockSmsFactory.sendVerificationOtp).toHaveBeenCalled() - - expect(postmanSpy).not.toHaveBeenCalled() - }) - it('should return TransactionNotFoundError when transaction ID does not exist', async () => { const result = await VerificationService.sendNewOtp({ ...mockSendNewFormOtpValidInput, @@ -389,7 +344,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -416,7 +371,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -436,7 +391,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -465,7 +420,7 @@ describe('Verification service', () => { const result = await VerificationService.sendNewOtp(expiredOtpInput) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -491,7 +446,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -535,7 +490,9 @@ describe('Verification service', () => { const error = new SmsSendError() - MockSmsFactory.sendVerificationOtp.mockReturnValueOnce(errAsync(error)) + MockPostmanSmsService.sendVerificationOtp.mockReturnValueOnce( + errAsync(error), + ) const field = generateFieldParams({ fieldType: BasicField.Mobile, _id: mockFieldIdObj as unknown as string, @@ -550,13 +507,13 @@ describe('Verification service', () => { transactionId: transaction._id, }) - expect(MockSmsFactory.sendVerificationOtp).toHaveBeenCalledWith( - MOCK_LOCAL_RECIPIENT, - MOCK_OTP, - MOCK_OTP_PREFIX, - new ObjectId(mockFormId), - MOCK_SENDER_IP, - ) + expect(MockPostmanSmsService.sendVerificationOtp).toHaveBeenCalledWith({ + formId: new ObjectId(mockFormId), + otp: MOCK_OTP, + recipientPhoneNumber: MOCK_LOCAL_RECIPIENT, + otpPrefix: MOCK_OTP_PREFIX, + senderIp: MOCK_SENDER_IP, + }) expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -572,13 +529,13 @@ describe('Verification service', () => { ) // Mock params default to mobile - expect(MockSmsFactory.sendVerificationOtp).toHaveBeenCalledWith( - MOCK_LOCAL_RECIPIENT, - MOCK_OTP, - MOCK_OTP_PREFIX, - new ObjectId(mockFormId), - MOCK_SENDER_IP, - ) + expect(MockPostmanSmsService.sendVerificationOtp).toHaveBeenCalledWith({ + formId: new ObjectId(mockFormId), + otp: MOCK_OTP, + recipientPhoneNumber: MOCK_LOCAL_RECIPIENT, + otpPrefix: MOCK_OTP_PREFIX, + senderIp: MOCK_SENDER_IP, + }) expect( MockFormsgSdk.verification.generateSignature, ).toHaveBeenCalledWith({ @@ -612,7 +569,7 @@ describe('Verification service', () => { } beforeEach(async () => { - MockSmsFactory.sendVerificationOtp.mockReturnValue(okAsync(true)) + MockPostmanSmsService.sendVerificationOtp.mockReturnValue(okAsync(true)) MockMailService.sendVerificationOtp.mockReturnValue(okAsync(true)) MockFormsgSdk.verification.generateSignature.mockReturnValue( MOCK_SIGNED_DATA, @@ -660,7 +617,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -687,7 +644,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -707,7 +664,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -736,7 +693,7 @@ describe('Verification service', () => { const result = await VerificationService.sendNewOtp(expiredOtpInput) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -762,7 +719,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() diff --git a/src/app/modules/verification/verification.service.ts b/src/app/modules/verification/verification.service.ts index e0cc038fbf..078deebfda 100644 --- a/src/app/modules/verification/verification.service.ts +++ b/src/app/modules/verification/verification.service.ts @@ -1,10 +1,7 @@ import mongoose from 'mongoose' import { errAsync, okAsync, ResultAsync } from 'neverthrow' -import { - featureFlags, - PAYMENT_CONTACT_FIELD_ID, -} from '../../../../shared/constants' +import { PAYMENT_CONTACT_FIELD_ID } from '../../../../shared/constants' import { BasicField } from '../../../../shared/types' import { startsWithSgPrefix } from '../../../../shared/utils/phone-num-validation' import { NUM_OTP_RETRIES } from '../../../../shared/utils/verification' @@ -22,7 +19,6 @@ import { SmsSendError, } from '../../services/postman-sms/postman-sms.errors' import PostmanSmsService from '../../services/postman-sms/postman-sms.service' -import { SmsFactory } from '../../services/sms/sms.factory' import { transformMongoError } from '../../utils/handle-mongo-error' import { compareHash, HashingError } from '../../utils/hash' import { @@ -30,7 +26,6 @@ import { MalformedParametersError, PossibleDatabaseError, } from '../core/core.errors' -import * as FeatureFlagService from '../feature-flags/feature-flags.service' import { FormNotFoundError } from '../form/form.errors' import * as FormService from '../form/form.service' @@ -455,10 +450,6 @@ const sendOtpForField = ( | OtpRequestError > => { const { fieldType, _id: fieldId } = field - const logMeta = { - action: 'sendOtpForField', - formId, - } switch (fieldType) { case BasicField.Mobile: return fieldId @@ -468,24 +459,6 @@ const sendOtpForField = ( shouldGenerateMobileOtp(form, fieldId, recipient), ) .andThen(() => { - return FeatureFlagService.getFeatureFlag( - featureFlags.postmanSms, - { - fallbackValue: false, - logMeta, - }, - ) - }) - .andThen((shouldUsePostmanSms) => { - if (!shouldUsePostmanSms) { - return SmsFactory.sendVerificationOtp( - recipient, - otp, - otpPrefix, - formId, - senderIp, - ) - } return PostmanSmsService.sendVerificationOtp({ recipientPhoneNumber: recipient, otp, diff --git a/src/app/modules/verification/verification.util.ts b/src/app/modules/verification/verification.util.ts index 7b1958ea7f..f13b4311a5 100644 --- a/src/app/modules/verification/verification.util.ts +++ b/src/app/modules/verification/verification.util.ts @@ -14,7 +14,6 @@ import { IVerificationSchema, MapRouteError, } from '../../../types' -import { smsConfig } from '../../config/features/sms.config' import { createLoggerWithLabel } from '../../config/logger' import { OtpRequestCountExceededError, @@ -273,10 +272,6 @@ export const mapRouteError: MapRouteError = ( } } -export const hasAdminExceededFreeSmsLimit = (smsCount: number): boolean => { - return smsCount > smsConfig.smsVerificationLimit -} - /** * Extracts an individual field's data from a transaction document. * @param transaction Transaction document diff --git a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.twilio.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.twilio.routes.spec.ts deleted file mode 100644 index 28ad725564..0000000000 --- a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.twilio.routes.spec.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { - createAuthedSession, - logoutSession, -} from '__tests__/integration/helpers/express-auth' -import { setupApp } from '__tests__/integration/helpers/express-setup' -import dbHandler from '__tests__/unit/backend/helpers/jest-db' -import { ObjectId } from 'bson' -import mongoose from 'mongoose' -import { errAsync } from 'neverthrow' -import supertest, { Session } from 'supertest-session' - -import config from 'src/app/config/config' -import getFormModel from 'src/app/models/form.server.model' -import getUserModel from 'src/app/models/user.server.model' -import { SecretsManagerError } from 'src/app/modules/core/core.errors' -import { IPopulatedForm } from 'src/types' - -import * as AdminFormService from '../../../../../../../app/modules/form/admin-form/admin-form.service' -import { secretsManager } from '../../../../../../../app/modules/form/admin-form/admin-form.service' -import * as SmsService from '../../../../../../services/sms/sms.service' -import { AdminFormsRouter } from '../admin-forms.routes' - -import { generateTwilioCredSecretKeyName } from './../../../../../../modules/form/admin-form/admin-form.utils' -import { TwilioCredentials } from './../../../../../../services/sms/sms.types' - -// Prevent rate limiting. -jest.mock('src/app/utils/limit-rate') - -// Avoid async refresh calls -jest.mock('src/app/modules/spcp/spcp.oidc.client.ts') - -const MockAdminFormService = jest.mocked(AdminFormService) - -const app = setupApp('/admin/forms', AdminFormsRouter, { - setupWithAuth: true, -}) - -const UserModel = getUserModel(mongoose) -const FormModel = getFormModel(mongoose) - -describe('admin-form.twilio.routes', () => { - let request: Session - - beforeAll(async () => await dbHandler.connect()) - beforeEach(async () => { - request = supertest(app) - }) - afterEach(async () => { - await dbHandler.clearDatabase() - jest.clearAllMocks() - }) - afterAll(async () => await dbHandler.closeDatabase()) - - describe('PUT /admin/forms/:formId/twilio', () => { - const MOCK_FORM_ID = new ObjectId().toHexString() - - const MOCK_ACCOUNT_SID = 'AC12345678' - const MOCK_API_KEY_SID = 'SK12345678' - const MOCK_API_KEY_SECRET = 'AZ12345678' - const MOCK_MESSAGING_SERVICE_SID = 'MG12345678' - - const TWILIO_CREDENTIALS: TwilioCredentials = { - accountSid: MOCK_ACCOUNT_SID, - apiKey: MOCK_API_KEY_SID, - apiSecret: MOCK_API_KEY_SECRET, - messagingServiceSid: MOCK_MESSAGING_SERVICE_SID, - } - - const MOCK_INVALID_ACCOUNT_SID = 'ZZ12345678' // Invalid AC prefix - - const INVALID_TWILIO_CREDENTIALS: TwilioCredentials = { - accountSid: MOCK_INVALID_ACCOUNT_SID, - apiKey: MOCK_API_KEY_SID, - apiSecret: MOCK_API_KEY_SECRET, - messagingServiceSid: MOCK_MESSAGING_SERVICE_SID, - } - - const MOCK_SUCCESSFUL_UPDATE = { - message: 'Successfully updated Twilio credentials', - } - - const MOCK_FORM = { - _id: MOCK_FORM_ID, - save: () => MOCK_FORM, - } as unknown as IPopulatedForm - - it('should return 200 on successful twilio credentials addition', async () => { - const { form: formToUpdate, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - const msgSrvcName = `formsg/${config.secretEnv}/form/${formToUpdate._id}/twilio` - - const createSecretsSpy = jest - .spyOn(secretsManager, 'createSecret') - .mockImplementationOnce(() => { - return { - promise: () => { - return Promise.resolve({ - Name: msgSrvcName, - }) - }, - } as any - }) - - const formSpy = jest - .spyOn(FormModel.prototype, 'save') - .mockImplementationOnce(() => null) - - // Actual - const response = await session - .put(`/admin/forms/${formToUpdate._id}/twilio`) - .send(TWILIO_CREDENTIALS) - - // Assert - expect(createSecretsSpy).toHaveBeenCalled() - expect(formSpy).toHaveBeenCalled() - expect(response.status).toEqual(200) - expect(response.body).toEqual(MOCK_SUCCESSFUL_UPDATE) - }) - - it('should return 200 on successful twilio credentials update', async () => { - const { form: formToUpdate, user } = - await dbHandler.insertFormWithMsgSrvcName() - const session = await createAuthedSession(user.email, request) - const msgSrvcName = formToUpdate.msgSrvcName - - const getSecretsSpy = jest - .spyOn(secretsManager, 'getSecretValue') - .mockImplementationOnce(() => { - return { - promise: () => { - return Promise.resolve({ - Name: msgSrvcName, - }) - }, - } as any - }) - - const twilioCacheSpy = jest - .spyOn(SmsService.twilioClientCache, 'del') - .mockReturnValueOnce(1) - - const putSecretsSpy = jest - .spyOn(secretsManager, 'putSecretValue') - .mockImplementationOnce(() => { - return { - promise: () => { - return Promise.resolve({ - Name: msgSrvcName, - }) - }, - } as any - }) - - const response = await session - .put(`/admin/forms/${formToUpdate._id}/twilio`) - .send(TWILIO_CREDENTIALS) - - expect(response.status).toEqual(200) - expect(response.body).toEqual(MOCK_SUCCESSFUL_UPDATE) - - expect(getSecretsSpy).toHaveBeenCalledWith({ - SecretId: msgSrvcName, - }) - expect(twilioCacheSpy).toHaveBeenCalledWith(msgSrvcName) - expect(putSecretsSpy).toHaveBeenCalledWith({ - SecretId: msgSrvcName, - SecretString: JSON.stringify(TWILIO_CREDENTIALS), - }) - }) - - it('should return 400 when twilio credentials are invalid', async () => { - const { form: formToUpdate, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - - // Actual - const response = await session - .put(`/admin/forms/${formToUpdate._id}/twilio`) - .send(INVALID_TWILIO_CREDENTIALS) - - // Assert - expect(response.status).toEqual(400) - }) - - it('should return 401 when user is not logged in', async () => { - const { form: formToUpdate, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - await logoutSession(request) - - const response = await session - .put(`/admin/forms/${formToUpdate._id}/twilio`) - .send(TWILIO_CREDENTIALS) - - expect(response.status).toEqual(401) - expect(response.body).toEqual({ message: 'User is unauthorized.' }) - }) - - it('should return 403 when user does not have permissions to update form', async () => { - const { user } = await dbHandler.insertFormCollectionReqs() - const session = await createAuthedSession(user.email, request) - - // Create separate user - const collabUser = ( - await dbHandler.insertFormCollectionReqs({ - userId: new ObjectId(), - mailName: 'collab-user', - shortName: 'collabUser', - }) - ).user - - const randomForm = await FormModel.create({ - title: 'form that user has no write access to', - admin: collabUser._id, - publicKey: 'some random key', - // Current user only has read access. - permissionList: [{ email: user.email }], - _id: MOCK_FORM_ID, - }) - - const response = await session - .put(`/admin/forms/${randomForm._id}/twilio`) - .send(TWILIO_CREDENTIALS) - - expect(response.status).toEqual(403) - expect(response.body).toEqual({ - message: `User ${user.email} not authorized to perform write operation on Form ${randomForm._id} with title: ${randomForm.title}.`, - }) - }) - - it('should return 404 when form to update cannot be found', async () => { - const { user } = await dbHandler.insertFormCollectionReqs() - const session = await createAuthedSession(user.email, request) - const invalidFormId = MOCK_FORM_ID - - const response = await session - .put(`/admin/forms/${invalidFormId}/twilio`) - .send(TWILIO_CREDENTIALS) - - expect(response.status).toEqual(404) - expect(response.body).toEqual({ message: 'Form not found' }) - }) - - it('should return 422 when id of user adding twilio credentials is not found', async () => { - const { form: formToUpdate, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - - // Delete user after login. - await dbHandler.clearCollection(UserModel.collection.name) - - const response = await session - .put(`/admin/forms/${formToUpdate._id}/twilio`) - .send(TWILIO_CREDENTIALS) - - expect(response.status).toEqual(422) - expect(response.body).toEqual({ message: 'User not found' }) - }) - - it('should return 500 when SecretsManagerError occurs whilst updating credentials', async () => { - const { form: formToUpdate, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - - jest - .spyOn(MockAdminFormService, 'createTwilioCredentials') - .mockReturnValueOnce(errAsync(new SecretsManagerError())) - - const response = await session - .put(`/admin/forms/${formToUpdate._id}/twilio`) - .send(TWILIO_CREDENTIALS) - - expect(response.status).toEqual(500) - expect(response.body).toEqual({ - message: 'Something went wrong. Please try again.', - }) - }) - }) - - describe('DELETE /admin/forms/:formId/twilio', () => { - const MOCK_FORM_ID = new ObjectId() - const MOCK_MSG_SRVC_NAME = generateTwilioCredSecretKeyName( - MOCK_FORM_ID.toHexString(), - ) - - const MOCK_SUCCESSFUL_DELETE_RESPONSE = { - message: 'Successfully deleted Twilio credentials', - } - - it('should return 200 on successful twilio credentials deletion', async () => { - const { form, user } = await dbHandler.insertFormWithMsgSrvcName({ - formId: MOCK_FORM_ID, - msgSrvcName: MOCK_MSG_SRVC_NAME, - }) - const session = await createAuthedSession(user.email, request) - const msgSrvcName = form.msgSrvcName - - const formSpy = jest - .spyOn(FormModel.prototype, 'save') - .mockImplementationOnce(() => null) - - const twilioCacheSpy = jest - .spyOn(SmsService.twilioClientCache, 'del') - .mockReturnValueOnce(1) - - const deleteSecretSpy = jest - .spyOn(secretsManager, 'deleteSecret') - .mockImplementationOnce(() => { - return { - promise: () => { - return Promise.resolve({ - Name: msgSrvcName, - }) - }, - } as any - }) - - // Actual - const response = await session.delete(`/admin/forms/${form._id}/twilio`) - - // Assert - expect(twilioCacheSpy).toHaveBeenCalledWith(msgSrvcName) - expect(formSpy).toHaveBeenCalled() - expect(deleteSecretSpy).toHaveBeenCalledWith({ - SecretId: msgSrvcName, - }) - expect(response.status).toEqual(200) - expect(response.body).toEqual(MOCK_SUCCESSFUL_DELETE_RESPONSE) - }) - - it('should return 401 when user is not logged in', async () => { - const { form, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - await logoutSession(request) - - const response = await session.delete(`/admin/forms/${form._id}/twilio`) - - expect(response.status).toEqual(401) - expect(response.body).toEqual({ message: 'User is unauthorized.' }) - }) - - it('should return 403 when user does not have permissions to delete credentials', async () => { - const { user } = await dbHandler.insertFormCollectionReqs() - const session = await createAuthedSession(user.email, request) - - // Create separate user - const collabUser = ( - await dbHandler.insertFormCollectionReqs({ - userId: new ObjectId(), - mailName: 'collab-user', - shortName: 'collabUser', - }) - ).user - - const randomForm = await FormModel.create({ - title: 'form that user has no write access to', - admin: collabUser._id, - publicKey: 'some random key', - // Current user only has read access. - permissionList: [{ email: user.email }], - _id: MOCK_FORM_ID, - }) - - const response = await session.delete( - `/admin/forms/${randomForm._id}/twilio`, - ) - - expect(response.status).toEqual(403) - expect(response.body).toEqual({ - message: `User ${user.email} not authorized to perform delete operation on Form ${randomForm._id} with title: ${randomForm.title}.`, - }) - }) - - it('should return 404 when form whose Twilio credentials should be deleted cannot be found', async () => { - const { user } = await dbHandler.insertFormCollectionReqs() - const session = await createAuthedSession(user.email, request) - const invalidFormId = MOCK_FORM_ID - - const response = await session.delete( - `/admin/forms/${invalidFormId}/twilio`, - ) - - expect(response.status).toEqual(404) - expect(response.body).toEqual({ message: 'Form not found' }) - }) - - it('should return 422 when id of user adding twilio credentials is not found', async () => { - const { form, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - - // Delete user after login. - await dbHandler.clearCollection(UserModel.collection.name) - - const response = await session.delete(`/admin/forms/${form._id}/twilio`) - - expect(response.status).toEqual(422) - expect(response.body).toEqual({ message: 'User not found' }) - }) - - it('should return 500 when SecretsManagerError occurs whilst updating credentials', async () => { - const { form, user } = await dbHandler.insertFormWithMsgSrvcName() - const session = await createAuthedSession(user.email, request) - - jest - .spyOn(MockAdminFormService, 'deleteTwilioCredentials') - .mockReturnValueOnce(errAsync(new SecretsManagerError())) - - const response = await session.delete(`/admin/forms/${form._id}/twilio`) - - expect(response.status).toEqual(500) - expect(response.body).toEqual({ - message: 'Something went wrong. Please try again.', - }) - }) - }) -}) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts index a300e0591a..aca2c4ce0d 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts @@ -249,20 +249,6 @@ AdminFormsFormRouter.put( AdminFormController.handleUpdateStartPage, ) -/** - * Retrieves the free sms counts used by a form's administrator and the sms verification quota - * @security session - * - * @returns 200 with the free sms counts and the quota - * @returns 401 when user does not exist in session - * @returns 404 when the formId is not found in the database - * @returns 500 when a database error occurs during retrieval - */ -AdminFormsFormRouter.get( - '/:formId([a-fA-F0-9]{24})/verified-sms/count/free', - AdminFormController.handleGetFreeSmsCountForFormAdmin, -) - AdminFormsFormRouter.route('/feedback') /** * Submit an admin form creating feedback diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts index d41fba4a81..5be6a6cfc2 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts @@ -16,7 +16,6 @@ import { AdminFormsPresignRouter } from './admin-forms.presign.routes' import { AdminFormsPreviewRouter } from './admin-forms.preview.routes' import { AdminFormsSettingsRouter } from './admin-forms.settings.routes' import { AdminFormsSubmissionsRouter } from './admin-forms.submissions.routes' -import { AdminFormsTwilioRouter } from './admin-forms.twilio.routes' export const AdminFormsRouter = Router() @@ -33,7 +32,6 @@ AdminFormsRouter.use(AdminFormsSubmissionsRouter) AdminFormsRouter.use(AdminFormsPreviewRouter) AdminFormsRouter.use(AdminFormsPresignRouter) AdminFormsRouter.use(AdminFormsLogicRouter) -AdminFormsRouter.use(AdminFormsTwilioRouter) AdminFormsRouter.use(AdminFormsPaymentsRouter) AdminFormsRouter.use(AdminFormsGoGovRouter) AdminFormsRouter.use(AdminFormsIssueRouter) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.twilio.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.twilio.routes.ts deleted file mode 100644 index 6eb9640189..0000000000 --- a/src/app/routes/api/v3/admin/forms/admin-forms.twilio.routes.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Router } from 'express' - -import * as AdminFormController from '../../../../../modules/form/admin-form/admin-form.controller' - -export const AdminFormsTwilioRouter = Router() - -AdminFormsTwilioRouter.route('/:formId([a-fA-F0-9]{24})/twilio') - /** - * Update the specified form twilio credentials - * @route PUT /:formId/twilio - * @security session - * - * @returns 200 with twilio credentials succesfully updated - * @returns 400 with twilio credentials are invalid - * @returns 401 when user is not logged in - * @returns 403 when user does not have permissions to update the form - * @returns 404 when form to update cannot be found - * @returns 422 when id of user who is updating the form cannot be found - * @returns 500 when database error occurs - */ - .put(AdminFormController.handleUpdateTwilio) - /** - * @returns 200 when twilio credentials successfully deleted - * @returns 401 when user does not exist in session - * @returns 403 when user does not have permissions to delete credentials - * @returns 404 when form to delete credentials cannot be found - * @returns 422 when user in session cannot be retrieved from the database - * @returns 500 when database error occurs - */ - .delete(AdminFormController.handleDeleteTwilio) diff --git a/src/app/routes/api/v3/forms/__tests__/public-forms.verification.routes.spec.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.verification.routes.spec.ts index a0baa88ff0..193f84a35a 100644 --- a/src/app/routes/api/v3/forms/__tests__/public-forms.verification.routes.spec.ts +++ b/src/app/routes/api/v3/forms/__tests__/public-forms.verification.routes.spec.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { setupApp } from '__tests__/integration/helpers/express-setup' -import MockTwilio from '__tests__/integration/helpers/twilio' import { generateDefaultField } from '__tests__/unit/backend/helpers/generate-form-data' import dbHandler from '__tests__/unit/backend/helpers/jest-db' import bcrypt from 'bcrypt' @@ -9,12 +8,11 @@ import { subMinutes, subYears } from 'date-fns' import { StatusCodes } from 'http-status-codes' import _ from 'lodash' import mongoose from 'mongoose' -import { okAsync } from 'neverthrow' +import { errAsync, okAsync } from 'neverthrow' import nodemailer from 'nodemailer' import Mail from 'nodemailer/lib/mailer' import session, { Session } from 'supertest-session' -import getFormModel from 'src/app/models/form.server.model' import { generateFieldParams, MOCK_HASHED_OTP, @@ -23,7 +21,7 @@ import { import getVerificationModel from 'src/app/modules/verification/verification.model' import MailService from 'src/app/services/mail/mail.service' import { SmsSendError } from 'src/app/services/postman-sms/postman-sms.errors' -import * as SmsService from 'src/app/services/sms/sms.service' +import PostmanSmsService from 'src/app/services/postman-sms/postman-sms.service' import * as OtpUtils from 'src/app/utils/otp' import { IVerificationSchema } from 'src/types' @@ -35,8 +33,6 @@ import { import { MOCK_OTP } from '../../../../../modules/verification/__tests__/verification.test.helpers' import { PublicFormsVerificationRouter } from '../public-forms.verification.routes' -const Form = getFormModel(mongoose) - const verificationApp = setupApp('/forms', PublicFormsVerificationRouter) const VerificationModel = getVerificationModel(mongoose) @@ -45,6 +41,8 @@ jest.mock('src/app/utils/limit-rate') // Avoid async refresh calls jest.mock('src/app/modules/spcp/spcp.oidc.client.ts') +jest.mock('src/app/services/postman-sms/postman-sms.service') +const MockPostmanSmsService = jest.mocked(PostmanSmsService) jest.mock('nodemailer', () => ({ createTransport: jest.fn().mockReturnValue({ @@ -258,10 +256,7 @@ describe('public-forms.verification.routes', () => { describe('POST /forms/:formId/fieldverifications/:transactionId/fields/:fieldId/otp/generate', () => { beforeEach(() => { - // @ts-ignore - MockTwilio.messages.create.mockResolvedValue({ - sid: 'mockSid', - }) + MockPostmanSmsService.sendVerificationOtp.mockReturnValue(okAsync(true)) }) it('should return 201 when parameters for email field are valid', async () => { @@ -315,50 +310,6 @@ describe('public-forms.verification.routes', () => { expect(response.body).toEqual(expectedResponse) }) - it('should return 400 when fieldType is mobile but the provided phone number is not valid', async () => { - // Arrange - const expectedResponse = { - message: - 'This phone number does not seem to be valid. Please try again with a valid phone number.', - } - - // Act - const response = await request - .post( - `/forms/${mockVerifiableFormId}/fieldverifications/${mockTransactionId}/fields/${mockMobileFieldId}/otp/generate`, - ) - .send({ - // 7 digits after +65 instead of 8 - answer: '+651234567', - }) - - // Assert - expect(response.status).toBe(StatusCodes.BAD_REQUEST) - expect(response.body).toEqual(expectedResponse) - }) - - it('should return 400 when otp data could not be retrieved from the form due to parameters being malformed', async () => { - // Arrange - // NOTE: This error is only thrown on interaction with the db, hence the db is mocked here - jest.spyOn(Form, 'getOtpData').mockResolvedValueOnce(null) - const expectedResponse = { - message: 'Sorry, something went wrong. Please refresh and try again.', - } - - // Act - const response = await request - .post( - `/forms/${mockVerifiableFormId}/fieldverifications/${mockTransactionId}/fields/${mockMobileFieldId}/otp/generate`, - ) - .send({ - answer: '+6512345678', - }) - - // Assert - expect(response.status).toBe(StatusCodes.BAD_REQUEST) - expect(response.body).toEqual(expectedResponse) - }) - it('should return 400 when the transaction has expired', async () => { // Arrange const { _id: expiredTransactionId } = await VerificationModel.create({ @@ -386,7 +337,10 @@ describe('public-forms.verification.routes', () => { it('should return 400 when the otp could not be sent and fieldType is mobile', async () => { // Arrange - MockTwilio.messages.create.mockRejectedValueOnce(new SmsSendError()) + + MockPostmanSmsService.sendVerificationOtp.mockReturnValue( + errAsync(new SmsSendError()), + ) const expectedResponse = { message: 'Sorry, something went wrong. Please refresh and try again.', } @@ -839,9 +793,7 @@ describe('public-forms.verification.routes', () => { const requestForSmsOtp = async (fieldId: string, answer: string) => { // Set that so no real mail is sent. - jest - .spyOn(SmsService, 'sendVerificationOtp') - .mockReturnValueOnce(okAsync(true)) + MockPostmanSmsService.sendVerificationOtp.mockReturnValueOnce(okAsync(true)) const response = await request .post( diff --git a/src/app/routes/api/v3/notifications/__tests__/notifications.routes.spec.ts b/src/app/routes/api/v3/notifications/__tests__/notifications.routes.spec.ts deleted file mode 100644 index aafb6c53c3..0000000000 --- a/src/app/routes/api/v3/notifications/__tests__/notifications.routes.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { setupApp } from '__tests__/integration/helpers/express-setup' -import supertest, { Session } from 'supertest-session' - -import { ITwilioSmsWebhookBody } from 'src/types' - -import { NotificationsRouter } from './../notifications.routes' - -// Prevent rate limiting. -jest.mock('src/app/utils/limit-rate') - -const app = setupApp('/notifications', NotificationsRouter, { - setupWithAuth: true, -}) - -describe('notifications.routes', () => { - let request: Session - beforeEach(async () => { - request = supertest(app) - }) - afterEach(async () => { - jest.clearAllMocks() - }) - - const MOCK_SUCCESSFUL_MESSAGE: ITwilioSmsWebhookBody = { - SmsSid: '12345', - SmsStatus: 'delivered', - MessageStatus: 'delivered', - To: '+12345678', - MessageSid: 'SM212312', - AccountSid: 'AC123456', - MessagingServiceSid: 'MG123456', - From: '+12345678', - ApiVersion: '2011-11-01', - } - - const MOCK_FAILED_MESSAGE: ITwilioSmsWebhookBody = { - SmsSid: '12345', - SmsStatus: 'failed', - MessageStatus: 'failed', - To: '+12345678', - MessageSid: 'SM212312', - AccountSid: 'AC123456', - MessagingServiceSid: 'MG123456', - From: '+12345678', - ApiVersion: '2011-11-01', - ErrorCode: 30001, - ErrorMessage: 'Twilio is down!', - } - - const TWILIO_SIGNATURE_HEADER_KEY = 'x-twilio-signature' - const MOCK_TWILIO_SIGNATURE = 'mockSignature' - - describe('POST notifications/twilio', () => { - it('should return 200 on sending successful delivery status message', async () => { - const response = await request - .post('/notifications/twilio') - .send(MOCK_SUCCESSFUL_MESSAGE) - .set(TWILIO_SIGNATURE_HEADER_KEY, MOCK_TWILIO_SIGNATURE) - - expect(response.status).toEqual(200) - expect(response.body).toBeEmpty() - }) - - it('should return 200 on sending failed delivery status message', async () => { - const response = await request - .post('/notifications/twilio') - .send(MOCK_FAILED_MESSAGE) - .set(TWILIO_SIGNATURE_HEADER_KEY, MOCK_TWILIO_SIGNATURE) - - expect(response.status).toEqual(200) - expect(response.body).toBeEmpty() - }) - - it('should return 400 on sending successful delivery status message without wilio signature', async () => { - const response = await request - .post('/notifications/twilio') - .send(MOCK_SUCCESSFUL_MESSAGE) - - expect(response.status).toEqual(400) - }) - }) -}) diff --git a/src/app/routes/api/v3/notifications/notifications.routes.ts b/src/app/routes/api/v3/notifications/notifications.routes.ts index 1091cfddce..d5415a16f2 100644 --- a/src/app/routes/api/v3/notifications/notifications.routes.ts +++ b/src/app/routes/api/v3/notifications/notifications.routes.ts @@ -1,7 +1,6 @@ import { Router } from 'express' import { handleStripeEventUpdates } from '../../../../modules/payments/stripe.events.controller' -import { handleTwilioSmsUpdates } from '../../../../modules/twilio/twilio.controller' import { BouncesRouter } from './bounces' @@ -9,19 +8,6 @@ export const NotificationsRouter = Router() NotificationsRouter.use('/bounces', BouncesRouter) -/** - * Receives and logs all SMS delivery status updates from Twilio webhook - * - * Path here is required to be synced with statusCallbackRoute under - * sms.service#sendSms - * - * @route POST /api/v3/notifications/twilio - * - * @returns 200 when message succesfully received and logged - * @returns 400 when request is not coming from Twilio or request body s invalid - */ -NotificationsRouter.post('/twilio', handleTwilioSmsUpdates) - /** * Receives and logs all payment updates from Stripe webhook * diff --git a/src/app/services/mail/__tests__/mail.service.spec.ts b/src/app/services/mail/__tests__/mail.service.spec.ts index f0bbbbb9b2..49c8e8d10b 100644 --- a/src/app/services/mail/__tests__/mail.service.spec.ts +++ b/src/app/services/mail/__tests__/mail.service.spec.ts @@ -1,15 +1,10 @@ -import ejs from 'ejs' import { cloneDeep } from 'lodash' import moment from 'moment-timezone' import { err, ok, okAsync } from 'neverthrow' import Mail, { Attachment } from 'nodemailer/lib/mailer' import { FormResponseMode, PaymentChannel } from 'shared/types' -import { extractFormLinkView } from 'src/app/modules/form/form.utils' -import { - MailGenerationError, - MailSendError, -} from 'src/app/services/mail/mail.errors' +import { MailSendError } from 'src/app/services/mail/mail.errors' import { MailService } from 'src/app/services/mail/mail.service' import { AutoreplySummaryRenderData, @@ -25,13 +20,7 @@ import { ISubmissionSchema, } from 'src/types' -import { - HASH_EXPIRE_AFTER_SECONDS, - stringifiedSmsWarningTiers, -} from '../../../../../shared/utils/verification' -import { smsConfig } from '../../../config/features/sms.config' -import * as FormService from '../../../modules/form/form.service' -import { formatAsPercentage } from '../../../utils/formatters' +import { HASH_EXPIRE_AFTER_SECONDS } from '../../../../../shared/utils/verification' const MOCK_VALID_EMAIL = 'to@example.com' const MOCK_VALID_EMAIL_2 = 'to2@example.com' @@ -1348,306 +1337,6 @@ describe('mail.service', () => { }) }) - describe('sendSmsVerificationDisabledEmail', () => { - const MOCK_FORM_ID = 'mockFormId' - const MOCK_FORM_TITLE = 'You are all individuals!' - const MOCK_INVALID_EMAIL = 'something wrong@a' - - const MOCK_FORM: IPopulatedForm = { - permissionList: [ - { email: MOCK_VALID_EMAIL_2 }, - { email: MOCK_VALID_EMAIL_3 }, - ], - admin: { - email: MOCK_VALID_EMAIL, - }, - title: MOCK_FORM_TITLE, - _id: MOCK_FORM_ID, - } as unknown as IPopulatedForm - - const MOCK_INVALID_EMAIL_FORM: IPopulatedForm = { - permissionList: [], - admin: { - email: MOCK_INVALID_EMAIL, - }, - title: MOCK_FORM_TITLE, - _id: MOCK_FORM_ID, - } as unknown as IPopulatedForm - - const generateAdminExpectedMailOptions = async (admin: string) => { - const result = - await MailUtils.generateSmsVerificationDisabledHtmlForAdmin({ - forms: [extractFormLinkView(MOCK_FORM, MOCK_APP_URL)], - smsVerificationLimit: - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - smsWarningTiers: stringifiedSmsWarningTiers, - }).map((emailHtml) => { - return { - to: admin, - from: MOCK_SENDER_STRING, - html: emailHtml, - subject: 'Free Mobile Number Verification Disabled', - replyTo: MOCK_SENDER_EMAIL, - bcc: MOCK_SENDER_EMAIL, - } - }) - return result._unsafeUnwrap() - } - - const generateCollabExpectedMailOptions = async ( - admin: string, - collab: string[], - ) => { - const result = - await MailUtils.generateSmsVerificationDisabledHtmlForCollab({ - form: extractFormLinkView(MOCK_FORM, MOCK_APP_URL), - smsVerificationLimit: - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - smsWarningTiers: stringifiedSmsWarningTiers, - }).map((emailHtml) => { - return { - to: admin, - cc: collab, - from: MOCK_SENDER_STRING, - html: emailHtml, - subject: 'Free Mobile Number Verification Disabled', - replyTo: MOCK_SENDER_EMAIL, - bcc: MOCK_SENDER_EMAIL, - } - }) - return result._unsafeUnwrap() - } - it('should send verified sms disabled emails successfully', async () => { - // Arrange - // sendMail should return mocked success response - sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') - sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') - jest - .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') - .mockReturnValueOnce(okAsync([MOCK_FORM])) - const expectedAdminMailOptions = - await generateAdminExpectedMailOptions(MOCK_VALID_EMAIL) - const expectedCollabMailOptions = await generateCollabExpectedMailOptions( - MOCK_VALID_EMAIL, - [MOCK_VALID_EMAIL_2, MOCK_VALID_EMAIL_3], - ) - - // Act - const actualResult = - await mailService.sendSmsVerificationDisabledEmail(MOCK_FORM) - - // Assert - expect(actualResult._unsafeUnwrap()).toEqual(true) - // Check arguments passed to sendNodeMail - expect(sendMailSpy).toHaveBeenCalledTimes(2) - expect(sendMailSpy).toHaveBeenCalledWith(expectedAdminMailOptions) - expect(sendMailSpy).toHaveBeenCalledWith(expectedCollabMailOptions) - }) - - it('should return MailSendError when the provided email is invalid', async () => { - // Arrange - jest - .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') - .mockReturnValueOnce(okAsync([MOCK_INVALID_EMAIL_FORM])) - - // Act - const actualResult = await mailService.sendSmsVerificationDisabledEmail( - MOCK_INVALID_EMAIL_FORM, - ) - - // Assert - expect(actualResult).toEqual( - err(new MailSendError('Invalid email error')), - ) - // Check arguments passed to sendNodeMail - expect(sendMailSpy).toHaveBeenCalledTimes(0) - }) - - it('should return MailGenerationError when the html template could not be created', async () => { - // Arrange - jest - .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') - .mockReturnValueOnce(okAsync([MOCK_INVALID_EMAIL_FORM])) - jest.spyOn(ejs, 'renderFile').mockRejectedValueOnce('no.') - - // Act - const actualResult = await mailService.sendSmsVerificationDisabledEmail( - MOCK_INVALID_EMAIL_FORM, - ) - - // Assert - expect(actualResult).toEqual( - err( - new MailGenerationError( - 'Error occurred whilst rendering mail template', - ), - ), - ) - // Check arguments passed to sendNodeMail - expect(sendMailSpy).toHaveBeenCalledTimes(0) - }) - }) - - describe('sendSmsVerificationWarningEmail', () => { - const MOCK_FORM_ID = 'mockFormId' - const MOCK_FORM_TITLE = 'You are all individuals!' - const MOCK_INVALID_EMAIL = 'something wrong@a' - - const MOCK_FORM: IPopulatedForm = { - permissionList: [ - { email: MOCK_VALID_EMAIL_2 }, - { email: MOCK_VALID_EMAIL_3 }, - ], - admin: { - email: MOCK_VALID_EMAIL, - }, - title: MOCK_FORM_TITLE, - _id: MOCK_FORM_ID, - } as unknown as IPopulatedForm - - const MOCK_INVALID_EMAIL_FORM: IPopulatedForm = { - permissionList: [], - admin: { - email: MOCK_INVALID_EMAIL, - }, - title: MOCK_FORM_TITLE, - _id: MOCK_FORM_ID, - } as unknown as IPopulatedForm - - const generateExpectedAdminMailOptions = async ( - count: number, - admin: string, - ) => { - const result = await MailUtils.generateSmsVerificationWarningHtmlForAdmin( - { - forms: [extractFormLinkView(MOCK_FORM, MOCK_APP_URL)], - numAvailable: (smsConfig.smsVerificationLimit - count).toLocaleString( - 'en-US', - ), - smsVerificationLimit: - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - }, - ).map((emailHtml) => { - return { - to: admin, - from: MOCK_SENDER_STRING, - html: emailHtml, - subject: 'Mobile Number Verification - Free Tier Limit Alert', - replyTo: MOCK_SENDER_EMAIL, - bcc: MOCK_SENDER_EMAIL, - } - }) - return result._unsafeUnwrap() - } - - const generateExpectedCollabMailOptions = async ( - count: number, - admin: string, - collab: string[], - ) => { - const result = - await MailUtils.generateSmsVerificationWarningHtmlForCollab({ - form: extractFormLinkView(MOCK_FORM, MOCK_APP_URL), - percentageUsed: formatAsPercentage( - count / smsConfig.smsVerificationLimit, - ), - smsVerificationLimit: - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - }).map((emailHtml) => { - return { - to: admin, - cc: collab, - from: MOCK_SENDER_STRING, - html: emailHtml, - subject: 'Mobile Number Verification - Free Tier Limit Alert', - replyTo: MOCK_SENDER_EMAIL, - bcc: MOCK_SENDER_EMAIL, - } - }) - return result._unsafeUnwrap() - } - - it('should send verified sms warning emails successfully', async () => { - // Arrange - const MOCK_COUNT = 1000 - jest - .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') - .mockReturnValueOnce(okAsync([MOCK_FORM])) - // sendMail should return mocked success response - sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') - const MOCK_FORM_COLLABS = MOCK_FORM.permissionList.map( - ({ email }) => email, - ) - - // Act - const actualResult = await mailService.sendSmsVerificationWarningEmail( - MOCK_FORM, - MOCK_COUNT, - ) - const expectedAdminMailOptions = await generateExpectedAdminMailOptions( - MOCK_COUNT, - MOCK_VALID_EMAIL, - ) - const expectedCollabMailOptions = await generateExpectedCollabMailOptions( - MOCK_COUNT, - MOCK_VALID_EMAIL, - MOCK_FORM_COLLABS, - ) - - // Assert - expect(actualResult._unsafeUnwrap()).toEqual(true) - // Check arguments passed to sendNodeMail - expect(sendMailSpy).toHaveBeenCalledTimes(2) - expect(sendMailSpy).toHaveBeenCalledWith(expectedAdminMailOptions) - expect(sendMailSpy).toHaveBeenCalledWith(expectedCollabMailOptions) - }) - - it('should return MailSendError when the provided email is invalid', async () => { - // Arrange - jest - .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') - .mockReturnValueOnce(okAsync([MOCK_INVALID_EMAIL_FORM])) - - // Act - const actualResult = await mailService.sendSmsVerificationWarningEmail( - MOCK_INVALID_EMAIL_FORM, - 1000, - ) - - // Assert - expect(actualResult).toEqual( - err(new MailSendError('Invalid email error')), - ) - // Check arguments passed to sendNodeMail - expect(sendMailSpy).toHaveBeenCalledTimes(0) - }) - - it('should return MailGenerationError when the html template could not be created', async () => { - // Arrange - jest - .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') - .mockReturnValueOnce(okAsync([MOCK_FORM])) - jest.spyOn(ejs, 'renderFile').mockRejectedValueOnce('no.') - - // Act - const actualResult = await mailService.sendSmsVerificationWarningEmail( - MOCK_INVALID_EMAIL_FORM, - 1000, - ) - - // Assert - expect(actualResult).toEqual( - err( - new MailGenerationError( - 'Error occurred whilst rendering mail template', - ), - ), - ) - // Check arguments passed to sendNodeMail - expect(sendMailSpy).toHaveBeenCalledTimes(0) - }) - }) - describe('sendPaymentConfirmationEmail', () => { const MOCK_INVALID_EMAIL = 'hello@world' const MOCK_FORM_TITLE = 'Formally Information' diff --git a/src/app/services/mail/mail.service.ts b/src/app/services/mail/mail.service.ts index b193ddf5ec..60a8ec3e73 100644 --- a/src/app/services/mail/mail.service.ts +++ b/src/app/services/mail/mail.service.ts @@ -2,14 +2,7 @@ import { render } from '@react-email/render' import tracer from 'dd-trace' import { get, inRange, isEmpty } from 'lodash' import moment from 'moment-timezone' -import { - err, - errAsync, - fromPromise, - okAsync, - Result, - ResultAsync, -} from 'neverthrow' +import { err, errAsync, fromPromise, Result, ResultAsync } from 'neverthrow' import Mail from 'nodemailer/lib/mailer' import promiseRetry from 'promise-retry' import validator from 'validator' @@ -17,29 +10,18 @@ import validator from 'validator' import { FormResponseMode, PaymentChannel } from '../../../../shared/types' import { centsToDollars } from '../../../../shared/utils/payments' import { getPaymentInvoiceDownloadUrlPath } from '../../../../shared/utils/urls' -import { - HASH_EXPIRE_AFTER_SECONDS, - stringifiedSmsWarningTiers, -} from '../../../../shared/utils/verification' +import { HASH_EXPIRE_AFTER_SECONDS } from '../../../../shared/utils/verification' import { BounceType, EmailAdminDataField, - IFormDocument, IFormHasEmailSchema, IPopulatedEncryptedForm, IPopulatedForm, - IPopulatedUser, ISubmissionSchema, } from '../../../types' import config from '../../config/config' -import { smsConfig } from '../../config/features/sms.config' import { createLoggerWithLabel } from '../../config/logger' -import * as FormService from '../../modules/form/form.service' -import { - extractFormLinkView, - getAdminEmails, -} from '../../modules/form/form.utils' -import { formatAsPercentage } from '../../utils/formatters' +import { getAdminEmails } from '../../modules/form/form.utils' import { BounceNotification } from '../../views/templates/BounceNotification' import MrfWorkflowCompletionEmail, { QuestionAnswer, @@ -52,12 +34,8 @@ import MrfWorkflowEmail, { import { EMAIL_HEADERS, EmailType } from './mail.constants' import { MailGenerationError, MailSendError } from './mail.errors' import { - AdminSmsDisabledData, - AdminSmsWarningData, AutoreplySummaryRenderData, BounceNotificationHtmlData, - CollabSmsDisabledData, - CollabSmsWarningData, IssueReportedNotificationData, MailOptions, MailServiceParams, @@ -74,10 +52,6 @@ import { generateLoginOtpHtml, generatePaymentConfirmationHtml, generatePaymentOnboardingHtml, - generateSmsVerificationDisabledHtmlForAdmin, - generateSmsVerificationDisabledHtmlForCollab, - generateSmsVerificationWarningHtmlForAdmin, - generateSmsVerificationWarningHtmlForCollab, generateSubmissionToAdminHtml, generateVerificationOtpHtml, isToFieldValid, @@ -680,175 +654,6 @@ export class MailService { ) } - /** - * Sends a email to the admin and collaborators of the form when the verified sms feature will be disabled. - * This happens only when the admin has hit a certain limit of sms verifications on his account. - * - * Note that the email sent to the admin and collaborators will differ. - * This is because the admin will see all of their forms that are affected but collaborators - * only see forms which they are a part of. - * - * @param form The form whose admin and collaborators will be issued the email - * @returns ok(true) when mail sending is successful - * @returns err(MailGenerationError) when there was an error in generating the html data for the mail - * @returns err(MailSendError) when there was an error in sending the mail - */ - sendSmsVerificationDisabledEmail = ( - form: Pick, - ): ResultAsync => { - // Step 1: Retrieve all public forms of admin that have sms verification enabled - return FormService.retrievePublicFormsWithSmsVerification(form.admin._id) - .andThen((forms) => { - // Step 2: Send the mail containing all the active forms to the admin - return this.sendDisabledMailForAdmin(forms, form.admin).map(() => forms) - }) - .andThen((forms) => { - // Step 3: Send to each individual form - return ResultAsync.combine( - forms.map((f) => - // If there are no collaborators, do not send out the email. - // Admin would already have received a summary email from Step 2. - f.permissionList.length - ? this.sendDisabledMailForCollab(f, form.admin) - : okAsync(true), - ), - ) - }) - .map(() => true) - } - - // Helper method to send an email to all the collaborators of a given form that would be affected by - // Sms verifications being disabled for the form. - // Note that this method also emails the admin to notify them that the collaborators have been informed. - sendDisabledMailForCollab = ( - form: IFormDocument, - admin: IPopulatedUser, - ): ResultAsync => { - const formLink = extractFormLinkView(form, this.#appUrl) - const htmlData: CollabSmsDisabledData = { - form: formLink, - smsVerificationLimit: - // Formatted using localeString so that the displayed number has commas - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - smsWarningTiers: stringifiedSmsWarningTiers, - } - const collaborators = form.permissionList.map(({ email }) => email) - const logMeta = { - form: formLink, - admin, - collaborators, - action: 'sendDisabledMailForCollab', - } - - return generateSmsVerificationDisabledHtmlForCollab(htmlData).andThen( - (mailHtml) => { - const mailOptions: MailOptions = { - to: admin.email, - cc: collaborators, - from: this.#senderFromString, - html: mailHtml, - subject: 'Free Mobile Number Verification Disabled', - replyTo: this.#officialMail, - bcc: this.#senderMail, - } - - logger.info({ - message: 'Attempting to email collaborators about form disabling', - meta: logMeta, - }) - - return this.#sendNodeMail(mailOptions, { - formId: form._id, - mailId: 'sendDisabledMailForCollab', - }) - }, - ) - } - - // Helper method to send an email to a form admin which contains a summary of - // which forms would be impacted by sms verifications being removed. - sendDisabledMailForAdmin = ( - forms: IPopulatedForm[], - admin: IPopulatedUser, - ): ResultAsync => { - const formLinks = forms.map((f) => extractFormLinkView(f, this.#appUrl)) - const logMeta = { - forms: formLinks, - admin, - action: 'sendDisabledMailForAdmin', - } - - const htmlData: AdminSmsDisabledData = { - forms: formLinks, - smsVerificationLimit: - // Formatted using localeString so that the displayed number has commas - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - smsWarningTiers: stringifiedSmsWarningTiers, - } - - return ( - // Step 1: Generate HTML data for admin - generateSmsVerificationDisabledHtmlForAdmin(htmlData).andThen( - (mailHtml) => { - const mailOptions: MailOptions = { - to: admin.email, - from: this.#senderFromString, - html: mailHtml, - subject: 'Free Mobile Number Verification Disabled', - replyTo: this.#officialMail, - bcc: this.#senderMail, - } - - logger.info({ - message: 'Attempting to email admin about form disabling', - meta: logMeta, - }) - - // Step 2: Send mail out to admin ONLY - return this.#sendNodeMail(mailOptions, { - mailId: 'sendDisabledMailForAdmin', - }) - }, - ) - ) - } - - /** - * Sends a warning email to the admin of the form when their current verified sms counts hits a limit - * @param form The form whose admin will be issued a warning - * @param smsVerifications The current total sms verifications for the form - * @returns ok(true) when mail sending is successful - * @returns err(MailGenerationError) when there was an error in generating the html data for the mail - * @returns err(MailSendError) when there was an error in sending the mail - */ - sendSmsVerificationWarningEmail = ( - form: Pick, - smsVerifications: number, - ): ResultAsync => { - // Step 1: Retrieve all public forms of admin that have sms verification enabled - return FormService.retrievePublicFormsWithSmsVerification(form.admin._id) - .andThen((forms) => { - // Step 2: Send the mail containing all the active forms to the admin - return this.sendWarningMailForAdmin( - forms, - form.admin, - smsVerifications, - ).map(() => forms) - }) - .andThen((forms) => { - // Step 3: Send to each individual form - return ResultAsync.combine( - forms.map((f) => - // If there are no collaborators, do not send out the email. - // Admin would already have received a summary email from Step 2. - f.permissionList.length - ? this.sendWarningMailForCollab(f, form.admin, smsVerifications) - : okAsync(true), - ), - ).map(() => true as const) - }) - } - /** * Sends a payment confirmation to a valid email * @param email the recipient email address @@ -919,110 +724,6 @@ export class MailService { return this.#sendNodeMail(mail, { mailId: 'paymentOnboarding' }) } - // Utility method to send a warning mail to the collaborators of a form. - // Note that this also sends the mail out to the admin of the form as well. - sendWarningMailForCollab = ( - form: IFormDocument, - admin: IPopulatedUser, - smsVerifications: number, - ): ResultAsync => { - const formLink = extractFormLinkView(form, this.#appUrl) - const percentageUsed = formatAsPercentage( - smsVerifications / smsConfig.smsVerificationLimit, - ) - const htmlData: CollabSmsWarningData = { - form: formLink, - percentageUsed, - smsVerificationLimit: - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - } - const collaborators = form.permissionList.map(({ email }) => email) - const logMeta = { - form: formLink, - admin, - collaborators, - smsVerifications, - action: 'sendWarningMailForCollab', - } - - // Step 1: Generate HTML data for collab - return generateSmsVerificationWarningHtmlForCollab(htmlData).andThen( - (mailHtml) => { - const mailOptions: MailOptions = { - to: admin.email, - cc: collaborators, - from: this.#senderFromString, - html: mailHtml, - subject: 'Mobile Number Verification - Free Tier Limit Alert', - replyTo: this.#officialMail, - bcc: this.#senderMail, - } - - logger.info({ - message: 'Attempting to warn collaborators about sms limits', - meta: logMeta, - }) - - // Step 2: Send mail out to admin and collab - return this.#sendNodeMail(mailOptions, { - formId: form._id, - mailId: 'sendWarningMailForCollab', - }) - }, - ) - } - - // Utility method to send a warning mail to the admin of a form. - // This is triggered when the admin's sms verification counts hits a limit. - // This informs the admin of all forms that use sms verification - sendWarningMailForAdmin = ( - forms: IPopulatedForm[], - admin: IPopulatedUser, - smsVerifications: number, - ): ResultAsync => { - const formLinks = forms.map((f) => extractFormLinkView(f, this.#appUrl)) - const htmlData: AdminSmsWarningData = { - forms: formLinks, - numAvailable: ( - smsConfig.smsVerificationLimit - smsVerifications - ).toLocaleString('en-US'), - smsVerificationLimit: - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - } - const logMeta = { - forms: formLinks, - admin, - smsVerifications, - action: 'sendWarningMailForAdmin', - } - - return ( - // Step 1: Generate HTML data for admin - generateSmsVerificationWarningHtmlForAdmin(htmlData).andThen( - (mailHtml) => { - const mailOptions: MailOptions = { - to: admin.email, - from: this.#senderFromString, - html: mailHtml, - subject: 'Mobile Number Verification - Free Tier Limit Alert', - replyTo: this.#officialMail, - bcc: this.#senderMail, - } - - logger.info({ - message: 'Attempting to warn admin about sms limits', - meta: logMeta, - }) - - // Step 2: Send mail out to admin ONLY - return this.#sendNodeMail(mailOptions, { - mailId: 'sendWarningMailForAdmin', - }) - }, - ) - ) - } - /** * Sends a notification email to the admin of the given form for issue * reported by the public users. diff --git a/src/app/services/mail/mail.types.ts b/src/app/services/mail/mail.types.ts index acb26f8808..343b88c55f 100644 --- a/src/app/services/mail/mail.types.ts +++ b/src/app/services/mail/mail.types.ts @@ -2,10 +2,8 @@ import Mail from 'nodemailer/lib/mailer' import { OperationOptions } from 'retry' import { AutoReplyOptions } from '../../../../shared/types' -import { SMS_WARNING_TIERS } from '../../../../shared/utils/verification' import { EmailAdminDataField, - FormLinkView, IFormSchema, IPopulatedForm, ISubmissionSchema, @@ -91,32 +89,6 @@ export type BounceNotificationHtmlData = { appName: string } -export type AdminSmsDisabledData = { - forms: FormLinkView[] -} & SmsVerificationTiers - -export type CollabSmsDisabledData = { - form: FormLinkView -} & SmsVerificationTiers - -export type AdminSmsWarningData = { - forms: FormLinkView[] - numAvailable: string - smsVerificationLimit: string -} - -export type CollabSmsWarningData = { - form: FormLinkView - percentageUsed: string - smsVerificationLimit: string -} - -type SmsVerificationTiers = { - smsVerificationLimit: string - // Ensure that all tiers are covered - smsWarningTiers: { [K in keyof typeof SMS_WARNING_TIERS]: string } -} - export type PaymentConfirmationData = { appName: string formTitle: string diff --git a/src/app/services/mail/mail.utils.ts b/src/app/services/mail/mail.utils.ts index 392b0a2880..622ac00b7a 100644 --- a/src/app/services/mail/mail.utils.ts +++ b/src/app/services/mail/mail.utils.ts @@ -11,13 +11,9 @@ import { generatePdfFromHtml } from '../../utils/convert-html-to-pdf' import { MailGenerationError, MailSendError } from './mail.errors' import { - AdminSmsDisabledData, - AdminSmsWarningData, AutoreplyHtmlData, AutoreplySummaryRenderData, BounceNotificationHtmlData, - CollabSmsDisabledData, - CollabSmsWarningData, IssueReportedNotificationData, PaymentConfirmationData, SubmissionToAdminHtmlData, @@ -206,70 +202,6 @@ export const isToFieldValid = (addresses: string | string[]): boolean => { return mails.every((addr) => validator.isEmail(addr)) } -export const generateSmsVerificationDisabledHtmlForAdmin = ( - htmlData: AdminSmsDisabledData, -): ResultAsync => { - const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-disabled-admin.server.view.html` - logger.info({ - message: 'generateSmsVerificationDisabledHtmlForAdmin', - meta: { - action: 'generateSmsVerificationDisabledHtmlForAdmin', - pathToTemplate, - __dirname, - cwd: process.cwd(), - }, - }) - return safeRenderFile(pathToTemplate, htmlData) -} - -export const generateSmsVerificationDisabledHtmlForCollab = ( - htmlData: CollabSmsDisabledData, -): ResultAsync => { - const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-disabled-collab.server.view.html` - logger.info({ - message: 'generateSmsVerificationDisabledHtmlForCollab', - meta: { - action: 'generateSmsVerificationDisabledHtmlForCollab', - pathToTemplate, - __dirname, - cwd: process.cwd(), - }, - }) - return safeRenderFile(pathToTemplate, htmlData) -} - -export const generateSmsVerificationWarningHtmlForAdmin = ( - htmlData: AdminSmsWarningData, -): ResultAsync => { - const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-warning-admin.view.html` - logger.info({ - message: 'generateSmsVerificationWarningHtmlForAdmin', - meta: { - action: 'generateSmsVerificationWarningHtmlForAdmin', - pathToTemplate, - __dirname, - cwd: process.cwd(), - }, - }) - return safeRenderFile(pathToTemplate, htmlData) -} - -export const generateSmsVerificationWarningHtmlForCollab = ( - htmlData: CollabSmsWarningData, -): ResultAsync => { - const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-warning-collab.view.html` - logger.info({ - message: 'generateSmsVerificationWarningHtmlForCollab', - meta: { - action: 'generateSmsVerificationWarningHtmlForCollab', - pathToTemplate, - __dirname, - cwd: process.cwd(), - }, - }) - return safeRenderFile(pathToTemplate, htmlData) -} - export const generatePaymentConfirmationHtml = ({ htmlData, }: { diff --git a/src/app/services/sms/__tests__/sms.factory.spec.ts b/src/app/services/sms/__tests__/sms.factory.spec.ts deleted file mode 100644 index 812208febd..0000000000 --- a/src/app/services/sms/__tests__/sms.factory.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { okAsync } from 'neverthrow' -import Twilio from 'twilio' - -import { ISms } from 'src/app/config/features/sms.config' - -import { createSmsFactory } from '../sms.factory' -import * as SmsService from '../sms.service' -import { TwilioConfig } from '../sms.types' - -// This is hoisted and thus a const cannot be passed in. -jest.mock('twilio', () => - jest.fn().mockImplementation(() => ({ - mocked: 'this is mocked', - })), -) - -jest.mock('src/app/services/sms/sms.dev.prismclient', () => () => ({})) - -jest.mock('../sms.service') -const MockSmsService = jest.mocked(SmsService) - -const MOCKED_TWILIO = { - mocked: 'this is mocked', -} as unknown as Twilio.Twilio - -describe('sms.factory', () => { - beforeEach(() => jest.clearAllMocks()) - - const MOCK_SMS_FEATURE: ISms = { - twilioAccountSid: 'ACrandomTwilioSid', - twilioApiKey: 'SKrandomTwilioAPIKEY', - twilioApiSecret: 'this is a super secret', - twilioMsgSrvcSid: 'formsg-is-great-pleasehelpme', - smsVerificationLimit: 10000, - } - const expectedTwilioConfig: TwilioConfig = { - msgSrvcSid: MOCK_SMS_FEATURE.twilioMsgSrvcSid, - client: MOCKED_TWILIO, - } - const SmsFactory = createSmsFactory(MOCK_SMS_FEATURE) - - it('should call SmsService counterpart when invoking sendVerificationOtp', async () => { - // Arrange - MockSmsService.sendVerificationOtp.mockReturnValue(okAsync(true)) - - const mockArguments: Parameters = [ - 'mockRecipient', - 'mockOtp', - 'mockOtpPrefix', - 'mockUserId', - 'mockSenderIp', - ] - - // Act - await SmsFactory.sendVerificationOtp(...mockArguments) - - // Assert - expect(MockSmsService.sendVerificationOtp).toHaveBeenCalledTimes(1) - expect(MockSmsService.sendVerificationOtp).toHaveBeenCalledWith( - ...mockArguments, - expectedTwilioConfig, - ) - }) -}) diff --git a/src/app/services/sms/__tests__/sms.service.spec.ts b/src/app/services/sms/__tests__/sms.service.spec.ts deleted file mode 100644 index 771bef9055..0000000000 --- a/src/app/services/sms/__tests__/sms.service.spec.ts +++ /dev/null @@ -1,200 +0,0 @@ -import dbHandler from '__tests__/unit/backend/helpers/jest-db' -import mongoose from 'mongoose' - -import getFormModel from 'src/app/models/form.server.model' -import { - DatabaseError, - MalformedParametersError, -} from 'src/app/modules/core/core.errors' -import { getMongoErrorMessage } from 'src/app/utils/handle-mongo-error' -import { FormOtpData, IFormSchema, IUserSchema } from 'src/types' - -import { FormResponseMode } from '../../../../../shared/types' -import { InvalidNumberError } from '../../postman-sms/postman-sms.errors' -import * as SmsService from '../sms.service' -import { LogType, SmsType, TwilioConfig } from '../sms.types' -import getSmsCountModel from '../sms_count.server.model' - -const FormModel = getFormModel(mongoose) -const SmsCountModel = getSmsCountModel(mongoose) - -// Test numbers provided by Twilio: -// https://www.twilio.com/docs/iam/test-credentials -const TWILIO_TEST_NUMBER = '+15005550006' -const MOCK_MSG_SRVC_SID = 'mockMsgSrvcSid' -const MOCK_SENDER_IP = '200.000.000.000' - -const twilioSuccessSpy = jest.fn().mockResolvedValue({ - status: 'testStatus', - sid: 'testSid', -}) - -const MOCK_VALID_CONFIG = { - msgSrvcSid: MOCK_MSG_SRVC_SID, - client: { - messages: { - create: twilioSuccessSpy, - }, - }, -} as unknown as TwilioConfig - -const twilioFailureSpy = jest.fn().mockResolvedValue({ - status: 'testStatus', - sid: undefined, - errorCode: 21211, -}) - -const MOCK_INVALID_CONFIG = { - msgSrvcSid: MOCK_MSG_SRVC_SID, - client: { - messages: { - create: twilioFailureSpy, - }, - }, -} as unknown as TwilioConfig - -const smsCountSpy = jest.spyOn(SmsCountModel, 'logSms') - -describe('sms.service', () => { - let testUser: IUserSchema - - beforeAll(async () => await dbHandler.connect()) - beforeEach(async () => { - const { user } = await dbHandler.insertFormCollectionReqs() - testUser = user - jest.clearAllMocks() - }) - afterEach(async () => await dbHandler.clearDatabase()) - afterAll(async () => await dbHandler.closeDatabase()) - - describe('sendVerificationOtp', () => { - let mockOtpData: FormOtpData - let testForm: IFormSchema - - beforeEach(async () => { - testForm = await FormModel.create({ - title: 'Test Form', - emails: [testUser.email], - admin: testUser._id, - responseMode: FormResponseMode.Email, - }) - - mockOtpData = { - form: testForm._id, - formAdmin: { - email: testUser.email, - userId: testUser._id, - }, - } - }) - - it('should return MalformedParametersError error when retrieved otpData is null', async () => { - // Arrange - // Return null on Form method - jest.spyOn(FormModel, 'getOtpData').mockResolvedValueOnce(null) - - // Act - const actualResult = await SmsService.sendVerificationOtp( - /* recipient= */ TWILIO_TEST_NUMBER, - /* otp= */ '111111', - /* otpPrefix= */ 'ABC', - /* formId= */ testForm._id, - /* senderIp= */ MOCK_SENDER_IP, - /* defaultConfig= */ MOCK_VALID_CONFIG, - ) - - // Assert - expect(actualResult._unsafeUnwrapErr()).toEqual( - new MalformedParametersError( - `Unable to retrieve otpData from ${testForm._id}`, - ), - ) - }) - - it('should log and send verification OTP when sending has no errors', async () => { - // Arrange - jest.spyOn(FormModel, 'getOtpData').mockResolvedValueOnce(mockOtpData) - - // Act - const actualResult = await SmsService.sendVerificationOtp( - /* recipient= */ TWILIO_TEST_NUMBER, - /* otp= */ '111111', - /* otpPrefix= */ 'ABC', - /* formId= */ testForm._id, - /* senderIp= */ MOCK_SENDER_IP, - /* defaultConfig= */ MOCK_VALID_CONFIG, - ) - - // Assert - expect(twilioSuccessSpy.mock.calls[0][0].statusCallback).toEqual( - expect.stringContaining('?senderIp'), - ) - - expect(actualResult._unsafeUnwrap()).toEqual(true) - // Logging should also have happened. - const expectedLogParams = { - smsData: mockOtpData, - msgSrvcSid: MOCK_MSG_SRVC_SID, - smsType: SmsType.Verification, - logType: LogType.success, - } - expect(smsCountSpy).toHaveBeenCalledWith(expectedLogParams) - }) - - it('should log failure and return InvalidNumberError when verification OTP fails to send due to invalid number', async () => { - // Arrange - jest.spyOn(FormModel, 'getOtpData').mockResolvedValueOnce(mockOtpData) - - // Act - const actualResult = await SmsService.sendVerificationOtp( - /* recipient= */ TWILIO_TEST_NUMBER, - /* otp= */ '111111', - /* otpPrefix= */ 'ABC', - /* formId= */ testForm._id, - /* senderIp= */ MOCK_SENDER_IP, - /* defaultConfig= */ MOCK_INVALID_CONFIG, - ) - - // Assert - expect(actualResult._unsafeUnwrapErr()).toEqual(new InvalidNumberError()) - // Logging should also have happened. - const expectedLogParams = { - smsData: mockOtpData, - msgSrvcSid: MOCK_MSG_SRVC_SID, - smsType: SmsType.Verification, - logType: LogType.failure, - } - expect(smsCountSpy).toHaveBeenCalledWith(expectedLogParams) - }) - }) - - describe('retrieveFreeSmsCounts', () => { - const VERIFICATION_SMS_COUNT = 3 - - it('should retrieve sms counts correctly for a specified user', async () => { - // Arrange - const retrieveSpy = jest.spyOn(SmsCountModel, 'retrieveFreeSmsCounts') - retrieveSpy.mockResolvedValueOnce(VERIFICATION_SMS_COUNT) - - // Act - const actual = await SmsService.retrieveFreeSmsCounts(testUser._id) - - // Assert - expect(actual._unsafeUnwrap()).toBe(VERIFICATION_SMS_COUNT) - }) - - it('should return a database error when retrieval fails', async () => { - // Arrange - const retrieveSpy = jest.spyOn(SmsCountModel, 'retrieveFreeSmsCounts') - retrieveSpy.mockRejectedValueOnce('ohno') - - // Act - const actual = await SmsService.retrieveFreeSmsCounts(testUser._id) - - // Assert - expect(actual._unsafeUnwrapErr()).toEqual( - new DatabaseError(getMongoErrorMessage('ohno')), - ) - }) - }) -}) diff --git a/src/app/services/sms/__tests__/sms_count.server.model.spec.ts b/src/app/services/sms/__tests__/sms_count.server.model.spec.ts deleted file mode 100644 index 401774158e..0000000000 --- a/src/app/services/sms/__tests__/sms_count.server.model.spec.ts +++ /dev/null @@ -1,649 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -import dbHandler from '__tests__/unit/backend/helpers/jest-db' -import { ObjectId } from 'bson' -import { cloneDeep, merge, omit } from 'lodash' -import mongoose from 'mongoose' - -import { smsConfig } from '../../../config/features/sms.config' -import { - IVerificationSmsCount, - IVerificationSmsCountSchema, - LogType, - SmsType, -} from '../sms.types' -import getSmsCountModel from '../sms_count.server.model' - -const SmsCount = getSmsCountModel(mongoose) - -const MOCK_SMSCOUNT_PARAMS = { - form: new ObjectId(), - formAdmin: { - email: 'mockEmail@example.com', - userId: new ObjectId(), - }, -} - -const MOCK_BOUNCED_SUBMISSION_PARAMS = { - form: new ObjectId(), - formAdmin: { - email: 'a@abc.com', - userId: new ObjectId(), - }, - collaboratorEmail: 'b@def.com', - recipientNumber: '+6581234567', - msgSrvcSid: 'mockMsgSrvcSid', - smsType: SmsType.BouncedSubmission, - logType: LogType.success, -} - -const MOCK_FORM_DEACTIVATED_PARAMS = { - form: new ObjectId(), - formAdmin: { - email: 'a@abc.com', - userId: new ObjectId(), - }, - collaboratorEmail: 'b@def.com', - recipientNumber: '+6581234567', - msgSrvcSid: 'mockMsgSrvcSid', - smsType: SmsType.DeactivatedForm, - logType: LogType.success, -} - -const MOCK_MSG_SRVC_SID = 'mockMsgSrvcSid' - -describe('SmsCount', () => { - beforeAll(async () => await dbHandler.connect()) - beforeEach(async () => await dbHandler.clearDatabase()) - afterAll(async () => await dbHandler.closeDatabase()) - - describe('FormDeactivatedSmsCountSchema', () => { - it('should create and save successfully', async () => { - const saved = await SmsCount.create(MOCK_FORM_DEACTIVATED_PARAMS) - - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual(MOCK_FORM_DEACTIVATED_PARAMS) - }) - - it('should reject if form is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_FORM_DEACTIVATED_PARAMS, 'form'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if formAdmin.email is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_FORM_DEACTIVATED_PARAMS, 'formAdmin.email'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if formAdmin.userId is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_FORM_DEACTIVATED_PARAMS, 'formAdmin.userId'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if collaboratorEmail is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_FORM_DEACTIVATED_PARAMS, 'collaboratorEmail'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if collaboratorEmail is invalid', async () => { - const invalidSmsCount = new SmsCount( - merge({}, MOCK_FORM_DEACTIVATED_PARAMS, { - collaboratorEmail: 'invalid', - }), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if recipientNumber is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_FORM_DEACTIVATED_PARAMS, 'recipientNumber'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if recipientNumber is invalid', async () => { - const invalidSmsCount = new SmsCount( - merge({}, MOCK_FORM_DEACTIVATED_PARAMS, { - recipientNumber: 'invalid', - }), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - }) - - describe('BouncedSubmissionSmsCountSchema', () => { - it('should create and save successfully', async () => { - const saved = await SmsCount.create(MOCK_BOUNCED_SUBMISSION_PARAMS) - - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual(MOCK_BOUNCED_SUBMISSION_PARAMS) - }) - - it('should reject if form is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_BOUNCED_SUBMISSION_PARAMS, 'form'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if formAdmin.email is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_BOUNCED_SUBMISSION_PARAMS, 'formAdmin.email'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if formAdmin.userId is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_BOUNCED_SUBMISSION_PARAMS, 'formAdmin.userId'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if collaboratorEmail is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_BOUNCED_SUBMISSION_PARAMS, 'collaboratorEmail'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if collaboratorEmail is invalid', async () => { - const invalidSmsCount = new SmsCount( - merge({}, MOCK_BOUNCED_SUBMISSION_PARAMS, { - collaboratorEmail: 'invalid', - }), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if recipientNumber is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_BOUNCED_SUBMISSION_PARAMS, 'recipientNumber'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if recipientNumber is invalid', async () => { - const invalidSmsCount = new SmsCount( - merge({}, MOCK_BOUNCED_SUBMISSION_PARAMS, { - recipientNumber: 'invalid', - }), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - }) - - describe('VerificationCount Schema', () => { - const twilioMsgSrvcSid = smsConfig.twilioMsgSrvcSid - - beforeAll(() => { - smsConfig.twilioMsgSrvcSid = MOCK_MSG_SRVC_SID - }) - - afterAll(() => { - smsConfig.twilioMsgSrvcSid = twilioMsgSrvcSid - }) - - it('should create and save successfully', async () => { - // Arrange - const smsCountParams = createVerificationSmsCountParams() - const expected = merge(smsCountParams, { - isOnboardedAccount: false, - }) - - // Act - const validSmsCount = new SmsCount(smsCountParams) - const saved = await validSmsCount.save() - - // Assert - // All fields should exist - // Object Id should be defined when successfully saved to MongoDB. - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - // Retrieve object and compare to params, remove indeterministic keys - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual(expected) - }) - - it('should save successfully, but not save fields that is not defined in the schema', async () => { - // Arrange - const smsCountParamsWithExtra = merge( - createVerificationSmsCountParams(), - { - extra: 'somethingExtra', - }, - ) - const expected = merge(omit(smsCountParamsWithExtra, 'extra'), { - isOnboardedAccount: false, - }) - - // Act - const validSmsCount = new SmsCount(smsCountParamsWithExtra) - const saved = await validSmsCount.save() - - // Assert - // All defined fields should exist - // Object Id should be defined when successfully saved to MongoDB. - expect(saved._id).toBeDefined() - // Extra key should not be saved - expect(Object.keys(saved)).not.toContain('extra') - expect(saved.createdAt).toBeInstanceOf(Date) - // Retrieve object and compare to params, remove indeterministic keys - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual(expected) - }) - - it('should save successfully and set isOnboarded to true when the credentials are different from default', async () => { - // Arrange - const verificationParams = merge( - createVerificationSmsCountParams({ - logType: LogType.success, - smsType: SmsType.Verification, - }), - { msgSrvcSid: 'i am different' }, - ) - - // Act - const validSmsCount = new SmsCount(verificationParams) - const saved = await validSmsCount.save() - - // Assert - // All fields should exist - // Object Id should be defined when successfully saved to MongoDB. - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - // Retrieve object and compare to params, remove indeterministic keys - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) as IVerificationSmsCountSchema - expect(omit(actualSavedObject, 'isOnboardedAccount')).toEqual( - verificationParams, - ) - expect(actualSavedObject.isOnboardedAccount).toBe(true) - }) - - it('should reject if form key is missing', async () => { - // Arrange - const malformedParams = omit(createVerificationSmsCountParams(), 'form') - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if formAdmin.email is missing', async () => { - // Arrange - const malformedParams = omit( - createVerificationSmsCountParams(), - 'formAdmin.email', - ) - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if formAdmin.userId is missing', async () => { - // Arrange - const malformedParams = omit( - createVerificationSmsCountParams(), - 'formAdmin.userId', - ) - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if logType is missing', async () => { - // Arrange - const malformedParams = omit( - createVerificationSmsCountParams(), - 'logType', - ) - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if logType is invalid', async () => { - // Arrange - const malformedParams = createVerificationSmsCountParams() - // @ts-ignore - malformedParams.logType = 'INVALID_LOG_TYPE' - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if smsType is missing', async () => { - // Arrange - const malformedParams = omit( - createVerificationSmsCountParams(), - 'smsType', - ) - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if smsType is invalid', async () => { - // Arrange - const malformedParams = createVerificationSmsCountParams() - // @ts-ignore - malformedParams.smsType = 'INVALID_SMS_TYPE' - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - }) - - describe('Statics', () => { - describe('logSms', () => { - const MOCK_FORM_ID = MOCK_SMSCOUNT_PARAMS.form - - it('should correctly log bounced submission SMS successes', async () => { - const saved = await SmsCount.logSms({ - logType: LogType.success, - smsType: SmsType.BouncedSubmission, - msgSrvcSid: MOCK_BOUNCED_SUBMISSION_PARAMS.msgSrvcSid, - smsData: omit(MOCK_BOUNCED_SUBMISSION_PARAMS, [ - 'msgSrvcSid', - 'smsType', - 'logType', - ]), - }) - - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual( - merge({}, MOCK_BOUNCED_SUBMISSION_PARAMS, { - logType: LogType.success, - }), - ) - }) - - it('should correctly log bounced submission SMS failures', async () => { - const saved = await SmsCount.logSms({ - logType: LogType.failure, - smsType: SmsType.BouncedSubmission, - msgSrvcSid: MOCK_BOUNCED_SUBMISSION_PARAMS.msgSrvcSid, - smsData: omit(MOCK_BOUNCED_SUBMISSION_PARAMS, [ - 'msgSrvcSid', - 'smsType', - 'logType', - ]), - }) - - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual( - merge({}, MOCK_BOUNCED_SUBMISSION_PARAMS, { - logType: LogType.failure, - }), - ) - }) - - it('should correctly log form deactivated SMS successes', async () => { - const saved = await SmsCount.logSms({ - logType: LogType.success, - smsType: SmsType.DeactivatedForm, - msgSrvcSid: MOCK_FORM_DEACTIVATED_PARAMS.msgSrvcSid, - smsData: omit(MOCK_FORM_DEACTIVATED_PARAMS, [ - 'msgSrvcSid', - 'smsType', - 'logType', - ]), - }) - - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual( - merge({}, MOCK_FORM_DEACTIVATED_PARAMS, { logType: LogType.success }), - ) - }) - - it('should correctly log form deactivated SMS failures', async () => { - const saved = await SmsCount.logSms({ - logType: LogType.failure, - smsType: SmsType.DeactivatedForm, - msgSrvcSid: MOCK_FORM_DEACTIVATED_PARAMS.msgSrvcSid, - smsData: omit(MOCK_FORM_DEACTIVATED_PARAMS, [ - 'msgSrvcSid', - 'smsType', - 'logType', - ]), - }) - - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual( - merge({}, MOCK_FORM_DEACTIVATED_PARAMS, { logType: LogType.failure }), - ) - }) - - it('should successfully log verification successes in the collection', async () => { - // Arrange - const initialCount = await SmsCount.countDocuments({}) - - // Act - const expectedLog = await logAndReturnExpectedLog({ - smsType: SmsType.Verification, - logType: LogType.success, - }) - - // Assert - const afterCount = await SmsCount.countDocuments({}) - // Should have 1 more document in the database since it is successful - expect(afterCount).toEqual(initialCount + 1) - - // Should contain OTP data and the correct sms/log type. - const actualLog = await SmsCount.findOne({ - form: MOCK_FORM_ID, - }).lean() - - expect(actualLog?._id).toBeDefined() - // Retrieve object and compare to params, remove indeterministic keys - const actualSavedObject = omit(actualLog, ['_id', 'createdAt', '__v']) - expect(actualSavedObject).toEqual(expectedLog) - }) - - it('should successfully log verification failures in the collection', async () => { - // Arrange - const initialCount = await SmsCount.countDocuments({}) - - // Act - const expectedLog = await logAndReturnExpectedLog({ - smsType: SmsType.Verification, - logType: LogType.failure, - }) - - // Assert - const afterCount = await SmsCount.countDocuments({}) - // Should have 1 more document in the database since it is successful - expect(afterCount).toEqual(initialCount + 1) - - // Should contain OTP data and the correct sms/log type. - const actualLog = await SmsCount.findOne({ - form: MOCK_FORM_ID, - }).lean() - - expect(actualLog?._id).toBeDefined() - // Retrieve object and compare to params, remove indeterministic keys - const actualSavedObject = omit(actualLog, ['_id', 'createdAt', '__v']) - expect(actualSavedObject).toEqual(expectedLog) - }) - - it('should reject if smsType is invalid', async () => { - await expect( - logAndReturnExpectedLog({ - // @ts-ignore - smsType: 'INVALID', - logType: LogType.failure, - }), - ).rejects.toThrow(mongoose.Error.ValidationError) - }) - - it('should reject if logType is invalid', async () => { - await expect( - logAndReturnExpectedLog({ - smsType: SmsType.Verification, - // @ts-ignore - logType: 'INVALID', - }), - ).rejects.toThrow(mongoose.Error.ValidationError) - }) - }) - }) -}) - -const createVerificationSmsCountParams = ({ - logType = LogType.success, - smsType = SmsType.Verification, -}: { - logType?: LogType - smsType?: SmsType -} = {}) => { - const smsCountParams: Partial = - cloneDeep(MOCK_SMSCOUNT_PARAMS) - smsCountParams.logType = logType - smsCountParams.smsType = smsType - smsCountParams.msgSrvcSid = MOCK_MSG_SRVC_SID - return smsCountParams -} - -const logAndReturnExpectedLog = async ({ - logType, - smsType, -}: { - logType: LogType - smsType: SmsType -}) => { - await SmsCount.logSms({ - smsData: MOCK_SMSCOUNT_PARAMS, - msgSrvcSid: MOCK_MSG_SRVC_SID, - smsType, - logType, - }) - - const expectedLog = { - ...MOCK_SMSCOUNT_PARAMS, - msgSrvcSid: MOCK_MSG_SRVC_SID, - smsType, - logType, - ...(smsType === SmsType.Verification && { - isOnboardedAccount: !(MOCK_MSG_SRVC_SID === smsConfig.twilioMsgSrvcSid), - }), - } - - return expectedLog -} diff --git a/src/app/services/sms/sms.dev.prismclient.ts b/src/app/services/sms/sms.dev.prismclient.ts deleted file mode 100644 index 01784abf37..0000000000 --- a/src/app/services/sms/sms.dev.prismclient.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { RequestClient } from 'twilio' - -import MailService from '../mail/mail.service' - -export class PrismClient extends RequestClient { - prismUrl: string - requestClient: InstanceType - constructor( - prismUrl: string, - requestClient: InstanceType, - ) { - super() - this.prismUrl = prismUrl - this.requestClient = requestClient - } - - #sendInternalMail = async (msg: string): Promise => { - await MailService.sendLocalDevMail('[mocktwilio] Captured SMS', msg) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - request(opts: any) { - opts.uri = opts.uri.replace(/^https:\/\/.*?\.twilio\.com/, this.prismUrl) - const resp = this.requestClient.request(opts) - - // eslint-disable-next-line no-console - this.#sendInternalMail(opts.data.Body).catch(console.error) - return resp - } -} diff --git a/src/app/services/sms/sms.factory.ts b/src/app/services/sms/sms.factory.ts deleted file mode 100644 index 6e299c3079..0000000000 --- a/src/app/services/sms/sms.factory.ts +++ /dev/null @@ -1,49 +0,0 @@ -import Twilio, { RequestClient } from 'twilio' - -import { useMockTwilio } from '../../config/config' -import { ISms, smsConfig } from '../../config/features/sms.config' - -import { PrismClient } from './sms.dev.prismclient' -import { sendVerificationOtp } from './sms.service' -import { TwilioConfig } from './sms.types' - -interface ISmsFactory { - sendVerificationOtp: ( - recipient: string, - otp: string, - otpPrefix: string, - formId: string, - senderIp: string, - ) => ReturnType -} - -// Exported for testing. -export const createSmsFactory = (smsConfig: ISms): ISmsFactory => { - const { twilioAccountSid, twilioApiKey, twilioApiSecret, twilioMsgSrvcSid } = - smsConfig - - const twilioClient = Twilio(twilioApiKey, twilioApiSecret, { - accountSid: twilioAccountSid, - httpClient: useMockTwilio - ? new PrismClient('http://127.0.0.1:4010', new RequestClient()) - : undefined, - }) - const twilioConfig: TwilioConfig = { - msgSrvcSid: twilioMsgSrvcSid, - client: twilioClient, - } - - return { - sendVerificationOtp: (recipient, otp, otpPrefix, formId, senderIp) => - sendVerificationOtp( - recipient, - otp, - otpPrefix, - formId, - senderIp, - twilioConfig, - ), - } -} - -export const SmsFactory = createSmsFactory(smsConfig) diff --git a/src/app/services/sms/sms.service.ts b/src/app/services/sms/sms.service.ts deleted file mode 100644 index 6a6397272b..0000000000 --- a/src/app/services/sms/sms.service.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { SecretsManager } from 'aws-sdk' -import mongoose from 'mongoose' -import { errAsync, okAsync, ResultAsync } from 'neverthrow' -import NodeCache from 'node-cache' -import Twilio from 'twilio' - -import { TwilioSmsStatsdTags } from 'src/types/twilio' - -import { isPhoneNumber } from '../../../../shared/utils/phone-num-validation' -import { AdminContactOtpData, FormOtpData } from '../../../types' -import config from '../../config/config' -import { createLoggerWithLabel } from '../../config/logger' -import getFormModel from '../../models/form.server.model' -import { - DatabaseError, - MalformedParametersError, - PossibleDatabaseError, -} from '../../modules/core/core.errors' -import { twilioStatsdClient } from '../../modules/twilio/twilio.statsd-client' -import { - getMongoErrorMessage, - transformMongoError, -} from '../../utils/handle-mongo-error' -import { - InvalidNumberError, - SmsSendError, -} from '../postman-sms/postman-sms.errors' -import { renderVerificationSms } from '../postman-sms/postman-sms.util' - -import { - LogSmsParams, - LogType, - SmsType, - TwilioConfig, - TwilioCredentials, -} from './sms.types' -import getSmsCountModel from './sms_count.server.model' - -const logger = createLoggerWithLabel(module) -const SmsCount = getSmsCountModel(mongoose) -const Form = getFormModel(mongoose) -const secretsManager = new SecretsManager({ region: config.aws.region }) -// The twilioClientCache is only initialized once even when sms.service.js is -// required by different files. -// Given that it is held in memory, when credentials are modified on -// secretsManager, the app will need to be redeployed to retrieve new -// credentials, or wait 10 seconds before. -export const twilioClientCache = new NodeCache({ - deleteOnExpire: true, - stdTTL: 10, -}) - -/** - * Retrieves credentials from secrets manager - * @param msgSrvcName The name of credential stored in the secret manager. - * @returns The credentials if available, null if secret does not exist or is malformed. - */ -const getCredentials = async ( - msgSrvcName: string, -): Promise => { - try { - const data = await secretsManager - .getSecretValue({ SecretId: msgSrvcName }) - .promise() - if (data.SecretString) { - const credentials = JSON.parse(data.SecretString) - if ( - credentials.accountSid && - credentials.apiKey && - credentials.apiSecret && - credentials.messagingServiceSid - ) { - return credentials - } - } - } catch (err) { - logger.error({ - message: 'Error retrieving credentials', - meta: { - action: 'getCredentials', - msgSrvcName, - }, - error: err, - }) - } - return null -} - -/** - * - * @param msgSrvcName The name of credential stored in the secret manager - * @returns A TwilioConfig containing the client and the sid linked to the msgSrvcName if defined, or the defaultConfig if not. - */ -const getTwilio = async ( - msgSrvcName: string | undefined, - defaultConfig: TwilioConfig, -): Promise => { - if (msgSrvcName) { - // Retrieve client and msgSrvcSid from cache - const cached = twilioClientCache.get(msgSrvcName) - if (cached !== undefined) { - return cached - } - // If not found in cache, retrieve credentials from AWS secret manager. - // Even if the msgSrvcName exists and a secret is returned, if the secret is - // malformed (missing required keys), null will still be returned. - // If null is returned, fallback to default Twilio config. - try { - const credentials = await getCredentials(msgSrvcName) - if (credentials !== null) { - const { accountSid, apiKey, apiSecret, messagingServiceSid } = - credentials - // Create twilioClient - const result: TwilioConfig = { - client: Twilio(apiKey, apiSecret, { accountSid }), - msgSrvcSid: messagingServiceSid, - } - // Add it to the cache - twilioClientCache.set(msgSrvcName, result) - logger.info({ - message: `Added ${msgSrvcName} to cache`, - meta: { - action: 'getTwilio', - msgSrvcName, - }, - }) - return result - } - } catch (err) { - logger.warn({ - message: - 'Failed to retrieve from cache. Defaulting to central Twilio client', - meta: { - action: 'getTwilio', - msgSrvcName, - }, - error: err, - }) - } - } - return defaultConfig -} - -const logSmsSend = (logParams: LogSmsParams) => { - return SmsCount.logSms(logParams).catch((error) => { - logger.error({ - message: 'Error logging sms count to database', - meta: { - action: 'logSmsSend', - ...logParams, - }, - error, - }) - }) -} - -/** - * Sends a message to a valid phone number - * @param twilioConfig The configuration used to send OTPs with - * @param twilioData.client The client to use - * @param twilioData.msgSrvcSid The message service sid to send from with. - * @param smsData The data for logging smsCount - * @param recipient The mobile number of the recipient - * @param message The message to send - * @param senderIp The ip address of the person triggering the SMS - */ -const sendSms = ( - twilioConfig: TwilioConfig, - smsData: FormOtpData | AdminContactOtpData, - recipient: string, - message: string, - smsType: SmsType, - senderIp?: string, -): ResultAsync => { - if (!isPhoneNumber(recipient)) { - logger.warn({ - message: `${recipient} is not a valid phone number`, - meta: { - action: 'send', - }, - }) - return errAsync(new InvalidNumberError()) - } - - const { client, msgSrvcSid } = twilioConfig - - const logMeta = { - action: 'send', - smsData, - smsType, - } - - const statusCallbackRoute = '/api/v3/notifications/twilio' - - const statusCallback = senderIp - ? `${config.app.appUrl}${statusCallbackRoute}?${encodeURI( - `senderIp=${senderIp}`, - )}` - : `${config.app.appUrl}${statusCallbackRoute}` - - return ResultAsync.fromPromise( - client.messages.create({ - to: recipient, - body: message, - from: msgSrvcSid, - forceDelivery: true, - statusCallback, - }), - (error) => { - logger.error({ - message: 'SMS send error', - meta: logMeta, - error, - }) - - return new SmsSendError('Error sending SMS to given number', { - originalError: error, - }) - }, - ) - .andThen(({ status, sid, errorCode, errorMessage }) => { - const ddTags: TwilioSmsStatsdTags = { - // msgSrvcSid not included to limit tag cardinality (for now?) - smsstatus: status, - errorcode: '0', - } - - if (!sid || errorCode) { - if (errorCode) { - ddTags.errorcode = `${errorCode}` - } - - logger.error({ - message: 'Encountered error code or missing sid after sending SMS', - meta: { - ...logMeta, - status, - errorCode, - errorMessage, - }, - }) - - twilioStatsdClient.increment('sms.send', 1, 1, ddTags) - - // Invalid number error code, throw a more reasonable error for error - // handling. - // See https://www.twilio.com/docs/api/errors/21211 - return errAsync( - errorCode === 21211 - ? new InvalidNumberError() - : new SmsSendError('Error sending SMS to given number', { - status, - errorCode, - errorMessage, - }), - ) - } - - twilioStatsdClient.increment('sms.send', 1, 1, ddTags) - - // No errors. - logger.info({ - message: 'Successfully sent sms', - meta: logMeta, - }) - - return okAsync(true as const) - }) - .map((result) => { - // Fire log sms success promise without waiting. - void logSmsSend({ - smsData, - smsType, - msgSrvcSid, - logType: LogType.success, - }) - - return result - }) - .mapErr((error) => { - // Fire log sms failure promise without waiting. - void logSmsSend({ - smsData, - smsType, - msgSrvcSid, - logType: LogType.failure, - }) - - return error - }) -} -/** - * Gets the correct twilio client for the form and sends an otp to a valid phonenumber - * @param recipient The phone number to send to - * @param otp The OTP to send - * @param otpPrefix The OTP Prefix to send - * @param formId Form id for retrieving otp data. - * @param senderIp The ip address of the person triggering the SMS - */ -export const sendVerificationOtp = ( - recipient: string, - otp: string, - otpPrefix: string, - formId: string, - senderIp: string, - defaultConfig: TwilioConfig, -): ResultAsync< - true, - DatabaseError | MalformedParametersError | SmsSendError | InvalidNumberError -> => { - logger.info({ - message: `Sending verification OTP for ${formId}`, - meta: { - action: 'sendVerificationOtp', - formId, - }, - }) - return ResultAsync.fromPromise(Form.getOtpData(formId), (error) => { - logger.error({ - message: `Database error occurred whilst retrieving form otp data`, - meta: { - action: 'sendVerificationOtp', - formId, - }, - error, - }) - - return new DatabaseError(getMongoErrorMessage(error)) - }).andThen((otpData) => { - if (!otpData) { - const errMsg = `Unable to retrieve otpData from ${formId}` - logger.error({ - message: errMsg, - meta: { - action: 'sendVerificationOtp', - formId, - }, - }) - - return errAsync(new MalformedParametersError(errMsg)) - } - - return ResultAsync.fromSafePromise< - TwilioConfig, - SmsSendError | InvalidNumberError - >(getTwilio(otpData.msgSrvcName, defaultConfig)).andThen((twilioConfig) => { - const message = renderVerificationSms(otp, otpPrefix) - - return sendSms( - twilioConfig, - otpData, - recipient, - message, - SmsType.Verification, - senderIp, - ) - }) - }) -} - -/** - * Retrieves the free sms count for a particular user - * @param userId The id of the user to retrieve the sms counts for - * @returns ok(count) when retrieval is successful - * @returns err(error) when retrieval fails due to a database error - */ -export const retrieveFreeSmsCounts = ( - userId: string, -): ResultAsync => { - return ResultAsync.fromPromise( - SmsCount.retrieveFreeSmsCounts(userId), - (error) => { - logger.error({ - message: `Retrieving free sms counts failed for ${userId}`, - meta: { - action: 'retrieveFreeSmsCounts', - userId, - error, - }, - }) - - return transformMongoError(error) - }, - ) -} diff --git a/src/app/services/sms/sms.types.ts b/src/app/services/sms/sms.types.ts deleted file mode 100644 index d0b9c674f8..0000000000 --- a/src/app/services/sms/sms.types.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { Document, Model } from 'mongoose' -import { Twilio } from 'twilio' - -import { FormPermission } from '../../../../shared/types' -import { - AdminContactOtpData, - FormOtpData, - IFormSchema, - IUserSchema, -} from '../../../types' - -export enum SmsType { - Verification = 'VERIFICATION', - AdminContact = 'ADMIN_CONTACT', - DeactivatedForm = 'DEACTIVATED_FORM', - BouncedSubmission = 'BOUNCED_SUBMISSION', -} - -export enum LogType { - failure = 'FAILURE', - success = 'SUCCESS', -} - -export type FormDeactivatedSmsData = { - form: IFormSchema['_id'] - formAdmin: { - email: IUserSchema['email'] - userId: IUserSchema['_id'] - } - collaboratorEmail: FormPermission['email'] - recipientNumber: string -} - -export type BouncedSubmissionSmsData = FormDeactivatedSmsData - -export type LogSmsParams = { - smsData: - | FormOtpData - | AdminContactOtpData - | FormDeactivatedSmsData - | BouncedSubmissionSmsData - msgSrvcSid: string - smsType: SmsType - logType: LogType -} - -export interface ISmsCount { - // The Twilio SID used to send the SMS. Not to be confused with msgSrvcName. - msgSrvcSid: string - logType: LogType - smsType: SmsType - createdAt?: Date -} - -export interface ISmsCountSchema extends ISmsCount, Document {} - -export interface IVerificationSmsCount extends ISmsCount { - form: IFormSchema['_id'] - formAdmin: { - email: string - userId: IUserSchema['_id'] - } - isOnboardedAccount: boolean -} - -export interface IVerificationSmsCountSchema - extends IVerificationSmsCount, - ISmsCountSchema { - isOnboardedAccount: boolean -} - -export interface IAdminContactSmsCount extends ISmsCount { - admin: IUserSchema['_id'] -} - -export interface IAdminContactSmsCountSchema - extends IAdminContactSmsCount, - ISmsCountSchema {} - -export interface IFormDeactivatedSmsCount - extends ISmsCount, - FormDeactivatedSmsData {} - -export interface IFormDeactivatedSmsCountSchema - extends ISmsCountSchema, - FormDeactivatedSmsData {} - -export interface IBouncedSubmissionSmsCount - extends ISmsCount, - BouncedSubmissionSmsData {} - -export interface IBouncedSubmissionSmsCountSchema - extends ISmsCountSchema, - BouncedSubmissionSmsData {} - -export interface ISmsCountModel extends Model { - logSms: (logParams: LogSmsParams) => Promise - /** - * Counts the number of sms which an admin has sent using default (formSG) credentials. - * NOTE: This counts across all forms which an admin has. - */ - retrieveFreeSmsCounts: (userId: string) => Promise -} - -export type TwilioCredentials = { - accountSid: string - apiKey: string - apiSecret: string - messagingServiceSid: string -} - -export class TwilioCredentialsData { - accountSid: string - apiKey: string - apiSecret: string - messagingServiceSid: string - - constructor(twilioCredentials: TwilioCredentials) { - const { accountSid, apiKey, apiSecret, messagingServiceSid } = - twilioCredentials - - this.accountSid = accountSid - this.apiKey = apiKey - this.apiSecret = apiSecret - this.messagingServiceSid = messagingServiceSid - } - - static fromString(credentials: string): TwilioCredentials | unknown { - try { - const twilioCredentials: TwilioCredentials = JSON.parse(credentials) - return new TwilioCredentialsData(twilioCredentials) - } catch (err) { - return err - } - } - - toString(): string { - const body: TwilioCredentials = { - accountSid: this.accountSid, - apiKey: this.apiKey, - apiSecret: this.apiSecret, - messagingServiceSid: this.messagingServiceSid, - } - return JSON.stringify(body) - } -} - -export type TwilioConfig = { - client: InstanceType - msgSrvcSid: string -} - -export interface BounceNotificationSmsParams { - recipient: string - recipientEmail: string - adminId: string - adminEmail: string - formId: string - formTitle: string -} diff --git a/src/app/services/sms/sms_count.server.model.ts b/src/app/services/sms/sms_count.server.model.ts deleted file mode 100644 index 234431b70c..0000000000 --- a/src/app/services/sms/sms_count.server.model.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { parsePhoneNumberFromString } from 'libphonenumber-js/mobile' -import { Mongoose, Schema } from 'mongoose' -import validator from 'validator' - -import { smsConfig } from '../../../app/config/features/sms.config' -import { FORM_SCHEMA_ID } from '../../models/form.server.model' -import { USER_SCHEMA_ID } from '../../models/user.server.model' - -import { - IAdminContactSmsCountSchema, - IBouncedSubmissionSmsCountSchema, - IFormDeactivatedSmsCountSchema, - ISmsCount, - ISmsCountModel, - ISmsCountSchema, - IVerificationSmsCountSchema, - LogSmsParams, - LogType, - SmsType, -} from './sms.types' - -const SMS_COUNT_SCHEMA_NAME = 'SmsCount' - -const VerificationSmsCountSchema = new Schema({ - form: { - type: Schema.Types.ObjectId, - ref: FORM_SCHEMA_ID, - required: true, - }, - formAdmin: { - email: { type: String, required: true }, - userId: { - type: Schema.Types.ObjectId, - ref: USER_SCHEMA_ID, - required: true, - }, - }, - isOnboardedAccount: { - type: Boolean, - }, -}) - -VerificationSmsCountSchema.pre( - 'save', - function (next) { - const formTwilioId = smsConfig.twilioMsgSrvcSid - this.isOnboardedAccount = !(this.msgSrvcSid === formTwilioId) - return next() - }, -) - -const AdminContactSmsCountSchema = new Schema({ - admin: { - type: Schema.Types.ObjectId, - ref: USER_SCHEMA_ID, - required: true, - }, -}) - -const bounceSmsCountSchema = { - form: { - type: Schema.Types.ObjectId, - ref: FORM_SCHEMA_ID, - required: true, - }, - formAdmin: { - email: { type: String, required: true }, - userId: { - type: Schema.Types.ObjectId, - ref: USER_SCHEMA_ID, - required: true, - }, - }, - collaboratorEmail: { - type: String, - validate: validator.isEmail, - required: true, - }, - recipientNumber: { - type: String, - validate: (value: string) => { - const phoneNumber = parsePhoneNumberFromString(value) - if (!phoneNumber) return false - return phoneNumber.isValid() - }, - required: true, - }, -} - -const FormDeactivatedSmsCountSchema = - new Schema(bounceSmsCountSchema) - -const BouncedSubmissionSmsCountSchema = - new Schema(bounceSmsCountSchema) - -const compileSmsCountModel = (db: Mongoose) => { - const SmsCountSchema = new Schema( - { - msgSrvcSid: { - type: String, - required: true, - }, - logType: { - type: String, - enum: Object.values(LogType), - required: true, - }, - smsType: { - type: String, - enum: Object.values(SmsType), - required: true, - }, - }, - { - timestamps: { - createdAt: true, - updatedAt: false, - }, - discriminatorKey: 'smsType', - }, - ) - - SmsCountSchema.statics.logSms = async function ({ - smsData, - msgSrvcSid, - smsType, - logType, - }: LogSmsParams) { - const schemaData: Omit = { - ...smsData, - msgSrvcSid, - smsType, - logType, - } - - const smsCount: ISmsCountSchema = new this(schemaData) - - return smsCount.save() - } - - SmsCountSchema.statics.retrieveFreeSmsCounts = async function ( - userId: string, - ) { - return this.countDocuments({ - 'formAdmin.userId': userId, - smsType: SmsType.Verification, - isOnboardedAccount: false, - }) - .read('secondary') - .exec() - } - - const SmsCountModel = db.model( - SMS_COUNT_SCHEMA_NAME, - SmsCountSchema, - ) - - // Adding Discriminators - SmsCountModel.discriminator(SmsType.Verification, VerificationSmsCountSchema) - SmsCountModel.discriminator(SmsType.AdminContact, AdminContactSmsCountSchema) - SmsCountModel.discriminator( - SmsType.DeactivatedForm, - FormDeactivatedSmsCountSchema, - ) - SmsCountModel.discriminator( - SmsType.BouncedSubmission, - BouncedSubmissionSmsCountSchema, - ) - - return SmsCountModel -} - -/** - * Retrieves the SmsCount model on the given Mongoose instance. If the model is - * not registered yet, the model will be registered and returned. - * @param db The mongoose instance to retrieve the SmsCount model from - * @returns The SmsCount model - */ -const getSmsCountModel = (db: Mongoose): ISmsCountModel => { - try { - return db.model(SMS_COUNT_SCHEMA_NAME) as ISmsCountModel - } catch { - return compileSmsCountModel(db) - } -} -export default getSmsCountModel diff --git a/src/app/utils/formatters.ts b/src/app/utils/formatters.ts deleted file mode 100644 index c2a197f162..0000000000 --- a/src/app/utils/formatters.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Transforms a number to a well formatted percentage to display -// Formats to integer precision -export const formatAsPercentage = (num: number): string => { - return `${Math.round(num * 100).toString()}%` -} diff --git a/src/app/views/templates/sms-verification-disabled-admin.server.view.html b/src/app/views/templates/sms-verification-disabled-admin.server.view.html deleted file mode 100644 index bd8c18c913..0000000000 --- a/src/app/views/templates/sms-verification-disabled-admin.server.view.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - -

    Dear form admin,

    -

    - You own forms with free SMS OTP verification on a Mobile Number field: -

    -
      - <% forms.forEach(function({title, link}) { %> -
    1. <%= title %>
    2. - <% }) %> -
    -

    - - As you have reached the free tier limit of <%= smsVerificationLimit %> - per account, SMS OTP verification has been automatically disabled on all - forms you own. - - Forms with Twilio already set up will not be affected. Respondents will - still be able to submit to your forms but will not be prompted to verify - their mobile numbers -

    - -

    - We would have previously notified all form admins upon your account - reaching <%= smsWarningTiers.LOW %>, <%= smsWarningTiers.MED %> and <%= - smsWarningTiers.HIGH %> free verifications. -

    -

    - If you need to send more SMS OTP verifications, please - - arrange advance billing with us. - -

    -

    FormSG Team

    - - diff --git a/src/app/views/templates/sms-verification-disabled-collab.server.view.html b/src/app/views/templates/sms-verification-disabled-collab.server.view.html deleted file mode 100644 index 9c3b00e779..0000000000 --- a/src/app/views/templates/sms-verification-disabled-collab.server.view.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - -

    Dear form admin,

    -

    - You are a collaborator on a form with free SMS OTP verification on a - Mobile Number field: - <%= form.title %>. -

    - -

    - - As the form owner has reached the account limit of <%= - smsVerificationLimit %> free verifications, SMS verification has been - automatically disabled on this form. - - Respondents will still be able to submit to this form but will not be - prompted to verify their mobile numbers. -

    - -

    - If you need to send more SMS OTP verifications, please - - arrange advance billing with us. - -

    -

    FormSG Team

    - - diff --git a/src/app/views/templates/sms-verification-warning-admin.view.html b/src/app/views/templates/sms-verification-warning-admin.view.html deleted file mode 100644 index 7304a86283..0000000000 --- a/src/app/views/templates/sms-verification-warning-admin.view.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - -

    Dear form admin,

    -

    - You own forms with free SMS OTP verification enabled on the Mobile Number - field: -

    -
      - <% forms.forEach(function({title, link}) { %> -
    1. <%= title %> .
    2. - <% }) %> -
    -

    - - Your account can use <%= numAvailable %> more free verifications until - free SMS verification is automatically disabled on all forms you own. - - Forms with Twilio already set up will not be affected. -

    -

    - If you need to send more than <%= smsVerificationLimit %> SMS - verifications, please - - arrange advance billing with us. - -

    -

    FormSG Team

    - - diff --git a/src/app/views/templates/sms-verification-warning-collab.view.html b/src/app/views/templates/sms-verification-warning-collab.view.html deleted file mode 100644 index 4da056c73e..0000000000 --- a/src/app/views/templates/sms-verification-warning-collab.view.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - -

    Dear form admin,

    -

    - You are a collaborator on a form with SMS OTP verification enabled on a - Mobile Number field: - <%= form.title %>. -

    -

    - The owner of this form has used up <%= percentageUsed %> of their free - tier limit for SMS verifications. - - SMS verification will be disabled for this form when the owner’s account - has reached <%= smsVerificationLimit %> verifications. - -

    -

    - If you need to send more than <%= smsVerificationLimit %> SMS - verifications, please - arrange advance billing with us. - -

    -

    FormSG Team

    - - diff --git a/src/app/views/templates/sms-verification-warning.view.html b/src/app/views/templates/sms-verification-warning.view.html deleted file mode 100644 index cc92b0d82b..0000000000 --- a/src/app/views/templates/sms-verification-warning.view.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - -

    Dear form admin,

    -

    - You own forms with free SMS OTP verification enabled on the Mobile Number - field: -

    -
      - <% forms.forEach(function({title, link}) { %> -
    1. <%= title %>
    2. - <% }) %> -
    -

    - - Your account can use <%= numAvailable %> more free verifications until - free SMS OTP verification is automatically disabled for all owned forms. - - Forms with Twilio already set up will not be affected. -

    -

    - If you need to send more than <%= smsVerificationLimit %> SMS OTP - verifications, please - arrange advance billing with us. - -

    -

    FormSG Team

    - - diff --git a/src/types/config.ts b/src/types/config.ts index e5a817ef72..5e6d9795ee 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -90,7 +90,6 @@ export type Config = { isTest: boolean isDevOrTest: boolean nodeEnv: Environment - useMockTwilio: boolean useMockPostmanSms: boolean port: number sessionSecret: string @@ -165,7 +164,6 @@ export interface IOptionalVarsSchema { otpLifeSpan: number submissionsTopUp: number nodeEnv: Environment - useMockTwilio: boolean useMockPostmanSms: boolean } banner: { diff --git a/src/types/form.ts b/src/types/form.ts index 2f8552dba7..920f0b5e1f 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -49,8 +49,6 @@ export type FormOtpData = { email: IUserSchema['email'] userId: IUserSchema['_id'] } - // Used for sending with the correct twilio - msgSrvcName?: string } /** @@ -256,22 +254,6 @@ export interface IFormSchema extends IForm, Document, PublicView { getDuplicateParams( overrideProps: OverrideProps, ): PickDuplicateForm & OverrideProps - - /** - * Updates the msgSrvcName of the form with the specified msgSrvcName - * @param msgSrvcName msgSrvcName to update the Form docuemnt with - * @param session transaction session in which update operation is a part of - */ - updateMsgSrvcName( - msgSrvcName: string, - session?: ClientSession, - ): Promise - - /** - * Deletes the msgSrvcName of the form - * @param session transaction session in which delete operation is a part of - */ - deleteMsgSrvcName(session?: ClientSession): Promise } /** @@ -392,15 +374,6 @@ export interface IFormModel extends Model { userId: IUserSchema['_id'], ): Promise - /** - * Retrieves all the public forms for a user which has sms verifications enabled - * @param userId The userId to retrieve the forms for - * @returns All public forms that have sms verifications enabled - */ - retrievePublicFormsWithSmsVerification( - userId: IUserSchema['_id'], - ): Promise - /** * Update the end page of form with given endpage object. * @param formId the id of the form to update @@ -486,11 +459,4 @@ export type IEmailFormModel = IFormModel & Model export type IMultirespondentFormModel = IFormModel & Model -export type IOnboardedForm = T & { - msgSrvcName: string -} - -export type FormLinkView = { - title: T['title'] - link: string -} +export type IOnboardedForm = T diff --git a/src/types/index.ts b/src/types/index.ts index 5fc43de979..0e5888db05 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,7 +19,6 @@ export * from './admin_verification' export * from './config' export * from './routing' export * from './email_mode_data' -export * from './twilio' export * from './workspace' export * from './payment' export * from './admin_feedback' diff --git a/src/types/twilio.ts b/src/types/twilio.ts deleted file mode 100644 index 969b7683ee..0000000000 --- a/src/types/twilio.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Based off Twilio documentation - https://www.twilio.com/docs/usage/webhooks/sms-webhooks -export interface ITwilioSmsWebhookBody { - SmsSid: string - SmsStatus: string - MessageStatus: string - To: string - MessageSid: string - AccountSid: string - MessagingServiceSid: string - From: string - ApiVersion: string - ErrorCode?: number // Only filled when it is 'failed' or 'undelivered' - ErrorMessage?: string // Only filled when it is 'failed' or 'undelivered' -} - -export type TwilioSmsStatsdTags = { - errorcode: string - smsstatus: string -} From 9f97c52fa05aa990beb143139ef36b027e0b3874 Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Mon, 2 Dec 2024 13:43:27 +0800 Subject: [PATCH 4/4] chore: bump version to v6.167.0 --- CHANGELOG.md | 27 ++++++++++++++++----------- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 22 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d756a4105f..436ca101e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,21 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). -#### [v6.166.1](https://github.com/opengovsg/FormSG/compare/v6.166.1...v6.166.1) +#### [v6.167.0](https://github.com/opengovsg/FormSG/compare/v6.166.1...v6.167.0) -- fix: trim previous text responses [`#7949`](https://github.com/opengovsg/FormSG/pull/7949) +- build: merge release v6.166.1 to develop [`#7961`](https://github.com/opengovsg/FormSG/pull/7961) +- feat(twilio): remove twilio be [`#7870`](https://github.com/opengovsg/FormSG/pull/7870) +- feat: update form create modal response options [`#7957`](https://github.com/opengovsg/FormSG/pull/7957) +- chore: include latest features in whats new page [`#7956`](https://github.com/opengovsg/FormSG/pull/7956) +- build: release v6.166.1 [`#7950`](https://github.com/opengovsg/FormSG/pull/7950) #### [v6.166.1](https://github.com/opengovsg/FormSG/compare/v6.166.0...v6.166.1) > 27 November 2024 +- fix: trim previous text responses [`#7949`](https://github.com/opengovsg/FormSG/pull/7949) - build: release v6.166.0 [`#7945`](https://github.com/opengovsg/FormSG/pull/7945) -- chore: bump version to v6.166.1 [`18d9c74`](https://github.com/opengovsg/FormSG/commit/18d9c74cde8739bc96a0833f3764cb518e4e34aa) +- chore: bump version to v6.166.1 [`844ee29`](https://github.com/opengovsg/FormSG/commit/844ee2912b45fd0d7dc3fedaab7b53adfa1375d8) #### [v6.166.0](https://github.com/opengovsg/FormSG/compare/v6.165.0...v6.166.0) @@ -70,18 +75,16 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - feat: unify email notification for response copy for storage and email modes [`#7903`](https://github.com/opengovsg/FormSG/pull/7903) - build: merge release v6.162.0 to develop [`#7904`](https://github.com/opengovsg/FormSG/pull/7904) - build: release v6.162.0 [`#7897`](https://github.com/opengovsg/FormSG/pull/7897) -- fix(deps): bump express-rate-limit from 7.4.0 to 7.4.1 [`#7892`](https://github.com/opengovsg/FormSG/pull/7892) -- chore: remove jest-axios-mock, jest-localstorage-mock [`#7893`](https://github.com/opengovsg/FormSG/pull/7893) -- fix(mail): failure to render mail [`#7890`](https://github.com/opengovsg/FormSG/pull/7890) -- fix: replace trash icon in MRF steps [`#7885`](https://github.com/opengovsg/FormSG/pull/7885) - chore: bump version to v6.163.0 [`7fa5e57`](https://github.com/opengovsg/FormSG/commit/7fa5e57727d21b2e99bab86200493b6e4e585215) -- chore: bump version to v6.162.0 [`c1b4ecf`](https://github.com/opengovsg/FormSG/commit/c1b4ecf2c9840c5be8af44e791145bb43b1447d0) -- update trash icon to pencil icon, remove delete functionality in InactiveStepBlock [`ae4db1d`](https://github.com/opengovsg/FormSG/commit/ae4db1d772781b6733deba75eae8442bcd4e33df) #### [v6.162.0](https://github.com/opengovsg/FormSG/compare/v6.161.0...v6.162.0) -> 14 November 2024 +> 17 November 2024 +- fix(deps): bump express-rate-limit from 7.4.0 to 7.4.1 [`#7892`](https://github.com/opengovsg/FormSG/pull/7892) +- chore: remove jest-axios-mock, jest-localstorage-mock [`#7893`](https://github.com/opengovsg/FormSG/pull/7893) +- fix(mail): failure to render mail [`#7890`](https://github.com/opengovsg/FormSG/pull/7890) +- fix: replace trash icon in MRF steps [`#7885`](https://github.com/opengovsg/FormSG/pull/7885) - chore: update react-email deps [`#7840`](https://github.com/opengovsg/FormSG/pull/7840) - build: merge release v6.161 to develop [`#7886`](https://github.com/opengovsg/FormSG/pull/7886) - feat(twilio): remove twilio from fe [`#7869`](https://github.com/opengovsg/FormSG/pull/7869) @@ -89,7 +92,9 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - feat: upgrade to use v4 of artifacts action [`#7881`](https://github.com/opengovsg/FormSG/pull/7881) - feat: remove openssl flag on deployment and build [`#7882`](https://github.com/opengovsg/FormSG/pull/7882) - build: release v6.161.0 [`#7884`](https://github.com/opengovsg/FormSG/pull/7884) -- chore: bump version to v6.162.0 [`a045ec5`](https://github.com/opengovsg/FormSG/commit/a045ec5883b4178b5b87416a7898fced9beb895b) +- chore: bump version to v6.162.0 [`c1b4ecf`](https://github.com/opengovsg/FormSG/commit/c1b4ecf2c9840c5be8af44e791145bb43b1447d0) +- update trash icon to pencil icon, remove delete functionality in InactiveStepBlock [`ae4db1d`](https://github.com/opengovsg/FormSG/commit/ae4db1d772781b6733deba75eae8442bcd4e33df) +- removed edit function on entire step box [`7394d00`](https://github.com/opengovsg/FormSG/commit/7394d007a3fa8a92bbf8f2e04f584b57351a35ab) #### [v6.161.0](https://github.com/opengovsg/FormSG/compare/v6.160.0...v6.161.0) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 90b19b352f..c5a9c15a06 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.166.1", + "version": "6.167.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.166.1", + "version": "6.167.0", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^2.8.2", diff --git a/frontend/package.json b/frontend/package.json index c38bee151d..683f7293be 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.166.1", + "version": "6.167.0", "homepage": ".", "type": "module", "private": true, diff --git a/package-lock.json b/package-lock.json index c376047e1c..56d6a9aef0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.166.1", + "version": "6.167.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.166.1", + "version": "6.167.0", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.536.0", diff --git a/package.json b/package.json index 26158fd167..e11a1a2157 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.166.1", + "version": "6.167.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG "