From a25bebfe8bf9e0edb5e8f16990b691f98782fc2c Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Tue, 9 May 2023 14:21:05 +0800 Subject: [PATCH 1/4] feat: add business field mutation to form-level (#6236) * add business field mutation to form-level * add reading of form business info for invoice generation * chore: remove unused imports * fix: trim to apply after editing intent has been completed * ui changes for biz info * chore: business updated toast grammar --- .../admin-form/settings/SettingsService.ts | 13 ++ .../BusinessInfoSection.tsx | 140 ++++++++++++++++++ .../PaymentSettingsSection.tsx | 12 +- .../features/admin-form/settings/mutations.ts | 17 +++ shared/constants/form.ts | 1 + shared/types/form/form.ts | 7 + src/app/models/form.server.model.ts | 7 + .../form/admin-form/admin-form.middlewares.ts | 4 + src/app/modules/payments/stripe.controller.ts | 30 ++-- src/types/form.ts | 2 + 10 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 frontend/src/features/admin-form/settings/components/PaymentSettingsSection/BusinessInfoSection.tsx diff --git a/frontend/src/features/admin-form/settings/SettingsService.ts b/frontend/src/features/admin-form/settings/SettingsService.ts index 0ef94ffb7d..2ddb570426 100644 --- a/frontend/src/features/admin-form/settings/SettingsService.ts +++ b/frontend/src/features/admin-form/settings/SettingsService.ts @@ -4,6 +4,7 @@ import { EmailFormSettings, FormSettings, SettingsUpdateDto, + StorageFormSettings, } from '~shared/types/form/form' import { ApiService } from '~services/ApiService' @@ -16,6 +17,11 @@ type UpdateEmailFormFn = ( settingsToUpdate: EmailFormSettings[T], ) => Promise +type UpdateStorageFormFn = ( + formId: string, + settingsToUpdate: StorageFormSettings[T], +) => Promise + type UpdateFormFn = ( formId: string, settingsToUpdate: FormSettings[T], @@ -106,6 +112,13 @@ export const updateFormWebhookRetries = async ( }) } +export const updateBusinessInfo: UpdateStorageFormFn<'business'> = async ( + formId, + newBusinessField: StorageFormSettings['business'], +) => { + return updateFormSettings(formId, { business: newBusinessField }) +} + /** * Internal function that calls the PATCH API. * @param formId the id of the form to update diff --git a/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/BusinessInfoSection.tsx b/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/BusinessInfoSection.tsx new file mode 100644 index 0000000000..407d2e2d89 --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/BusinessInfoSection.tsx @@ -0,0 +1,140 @@ +import { + ChangeEventHandler, + KeyboardEventHandler, + useCallback, + useRef, + useState, +} from 'react' +import { FormControl } from '@chakra-ui/react' + +import { + AgencyBase, + FormResponseMode, + StorageFormSettings, +} from '~shared/types' + +import FormLabel from '~components/FormControl/FormLabel' +import Input from '~components/Input' + +import { useAdminForm } from '~features/admin-form/common/queries' + +import { useMutateFormSettings } from '../../mutations' +import { useAdminFormSettings } from '../../queries' + +interface BusinessFieldInputProps { + initialValue: string + handleMutation: (newAddress: string) => void + placeholder: string +} +const BusinessFieldInput = ({ + initialValue, + handleMutation, + placeholder, +}: BusinessFieldInputProps): JSX.Element => { + const [value, setValue] = useState(initialValue) + + const inputRef = useRef(null) + + const handleValueChange: ChangeEventHandler = useCallback( + (e) => { + setValue(e.target.value) + }, + [], + ) + + const handleBlur = useCallback(() => { + if (value === initialValue) return + const trimmedValue = value.trim() + handleMutation(trimmedValue) + setValue(trimmedValue) + }, [handleMutation, value, initialValue]) + + const handleKeydown: KeyboardEventHandler = useCallback( + (e) => { + if (e.key === 'Enter') { + e.preventDefault() + inputRef.current?.blur() + } + }, + [], + ) + + return ( + + ) +} + +const BusinessInfoBlock = ({ + settings, + agencyDefaults, +}: { + settings: StorageFormSettings + agencyDefaults: AgencyBase['business'] +}) => { + const { mutateFormBusiness } = useMutateFormSettings() + const handleAddressMutation = (newAddress: string) => { + mutateFormBusiness.mutate({ address: newAddress }) + } + const handleGstRegNoMutation = (newGstRegNo: string) => { + mutateFormBusiness.mutate({ gstRegNo: newGstRegNo }) + } + return ( + <> + + + GST Registration Number + + + + + + Business Address + + + + + ) +} + +export const BusinessInfoSection = () => { + const { data: settings, isLoading } = useAdminFormSettings() + const { data: adminSettings } = useAdminForm() + + if ( + isLoading || + !settings || + settings.responseMode !== FormResponseMode.Encrypt || + !adminSettings + ) { + return <> + } + + return ( + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/PaymentSettingsSection.tsx b/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/PaymentSettingsSection.tsx index ace68d26bb..13692fc702 100644 --- a/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/PaymentSettingsSection.tsx +++ b/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/PaymentSettingsSection.tsx @@ -1,4 +1,11 @@ -import { Flex, FormControl, Icon, Skeleton, Text } from '@chakra-ui/react' +import { + Divider, + Flex, + FormControl, + Icon, + Skeleton, + Text, +} from '@chakra-ui/react' import { FormResponseMode, PaymentChannel } from '~shared/types' @@ -8,6 +15,7 @@ import Input from '~components/Input' import { useAdminFormPayments, useAdminFormSettings } from '../../queries' +import { BusinessInfoSection } from './BusinessInfoSection' import { StripeConnectButton } from './StripeConnectButton' const PaymentsAccountValidation = () => { @@ -128,6 +136,8 @@ export const PaymentSettingsSection = (): JSX.Element => { <> + + ) } diff --git a/frontend/src/features/admin-form/settings/mutations.ts b/frontend/src/features/admin-form/settings/mutations.ts index 8821e8aaf5..fe985ee436 100644 --- a/frontend/src/features/admin-form/settings/mutations.ts +++ b/frontend/src/features/admin-form/settings/mutations.ts @@ -9,6 +9,7 @@ import { FormResponseMode, FormSettings, FormStatus, + StorageFormSettings, } from '~shared/types/form/form' import { TwilioCredentials } from '~shared/types/twilio' @@ -25,6 +26,7 @@ import { createStripeAccount, deleteTwilioCredentials, unlinkStripeAccount, + updateBusinessInfo, updateFormAuthType, updateFormCaptcha, updateFormEmails, @@ -287,6 +289,20 @@ export const useMutateFormSettings = () => { }, ) + const mutateFormBusiness = useMutation( + (businessInfo: StorageFormSettings['business']) => + updateBusinessInfo(formId, businessInfo), + { + onSuccess: (newData) => { + handleSuccess({ + newData, + toastDescription: `Business information has been updated.`, + }) + }, + onError: handleError, + }, + ) + return { mutateWebhookRetries, mutateFormWebhookUrl, @@ -298,6 +314,7 @@ export const useMutateFormSettings = () => { mutateFormTitle, mutateFormAuthType, mutateFormEsrvcId, + mutateFormBusiness, } } diff --git a/shared/constants/form.ts b/shared/constants/form.ts index 32e2a46efe..d0cbbcd22b 100644 --- a/shared/constants/form.ts +++ b/shared/constants/form.ts @@ -41,6 +41,7 @@ export const STORAGE_FORM_SETTINGS_FIELDS = [ 'payments_channel', 'payments_field', 'publicKey', + 'business', ] export const ADMIN_FORM_META_FIELDS = [ diff --git a/shared/types/form/form.ts b/shared/types/form/form.ts index 6817e28c0e..10a19d34c7 100644 --- a/shared/types/form/form.ts +++ b/shared/types/form/form.ts @@ -81,6 +81,11 @@ export type FormPaymentsField = { description?: string } +export type FormBusinessField = { + address?: string + gstRegNo?: string +} + export interface FormBase { title: string admin: UserDto['_id'] @@ -120,6 +125,7 @@ export interface StorageFormBase extends FormBase { publicKey: string payments_channel: FormPaymentsChannel payments_field: FormPaymentsField + business?: FormBusinessField } /** @@ -246,6 +252,7 @@ export type EndPageUpdateDto = FormEndPage export type FormPermissionsDto = FormPermission[] export type PermissionsUpdateDto = FormPermission[] export type PaymentsUpdateDto = FormPaymentsField +export type BusinessUpdateDto = FormBusinessField export type SendFormOtpResponseDto = { otpPrefix: string diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 3a0701b9b5..31e8960b1e 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -173,6 +173,13 @@ const EncryptedFormSchema = new Schema({ }, }, }, + + business: { + type: { + address: { type: String, required: true, trim: true }, + gstRegNo: { type: String, required: true, trim: true }, + }, + }, }) const EncryptedFormDocumentSchema = diff --git a/src/app/modules/form/admin-form/admin-form.middlewares.ts b/src/app/modules/form/admin-form/admin-form.middlewares.ts index 75147f747e..cd03eb51bb 100644 --- a/src/app/modules/form/admin-form/admin-form.middlewares.ts +++ b/src/app/modules/form/admin-form/admin-form.middlewares.ts @@ -28,6 +28,10 @@ export const updateSettingsValidator = celebrate({ url: Joi.string().uri().allow(''), isRetryEnabled: Joi.boolean(), }).min(1), + business: Joi.object({ + address: Joi.string().allow(''), + gstRegNo: Joi.string().allow(''), + }), }) .min(1) .custom((value, helpers) => verifyValidUnicodeString(value, helpers)), diff --git a/src/app/modules/payments/stripe.controller.ts b/src/app/modules/payments/stripe.controller.ts index 180b3d144a..fe07107383 100644 --- a/src/app/modules/payments/stripe.controller.ts +++ b/src/app/modules/payments/stripe.controller.ts @@ -367,28 +367,36 @@ export const downloadPaymentInvoice: ControllerHandler<{ // convert to pdf and return .then((receiptUrlResponse) => { const html = receiptUrlResponse.data - const businessInfo = (populatedForm as IPopulatedForm).admin.agency - .business + const agencyBusinessInfo = (populatedForm as IPopulatedForm).admin + .agency.business + const formBusinessInfo = populatedForm.business + + const businessAddress = [ + formBusinessInfo?.address, + agencyBusinessInfo?.address, + ].find(Boolean) + + const businessGstRegNo = [ + formBusinessInfo?.gstRegNo, + agencyBusinessInfo?.gstRegNo, + ].find(Boolean) // we will still continute the invoice generation even if there's no address/gstregno - if ( - !businessInfo || - !businessInfo.address || - !businessInfo.gstRegNo - ) + if (!businessAddress || !businessGstRegNo) logger.warn({ message: - 'Some business info not available during invoice generation', + 'Some business info not available during invoice generation. Expecting either agency or form to have business info', meta: { action: 'downloadPaymentInvoice', payment, agencyName: populatedForm.admin.agency.fullName, - businessInfo: businessInfo, + agencyBusinessInfo, + formBusinessInfo, }, }) const invoiceHtml = convertToInvoiceFormat(html, { - address: businessInfo?.address || '', - gstRegNo: businessInfo?.gstRegNo || '', + address: businessAddress || '', + gstRegNo: businessGstRegNo || '', formTitle: populatedForm.title, submissionId: payment.completedPayment?.submissionId || '', }) diff --git a/src/types/form.ts b/src/types/form.ts index 066bcef95d..8f3433f9d7 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -13,6 +13,7 @@ import type { Merge, SetOptional } from 'type-fest' import { AdminDashboardFormMetaDto, FormBase, + FormBusinessField, FormEndPage, FormField, FormFieldDto, @@ -276,6 +277,7 @@ export interface IEncryptedForm extends IForm { // are not defined in DB. See https://github.com/Automattic/mongoose/issues/5310 payments_channel: FormPaymentsChannel payments_field: FormPaymentsField + business?: FormBusinessField emails?: never } From 8c1d6dd07f13492189debf4569f806c8e2bb9f2a Mon Sep 17 00:00:00 2001 From: Lin Huiqing <37061143+LinHuiqing@users.noreply.github.com> Date: Tue, 9 May 2023 17:51:08 +0800 Subject: [PATCH 2/4] feat: control payment methods through stripe dashboard (#6272) feat: control payment methods through dashboard --- .../encrypt-submission/encrypt-submission.controller.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts index e9b3c9979f..136b191405 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -443,10 +443,10 @@ const submitEncryptModeForm: ControllerHandler< const createPaymentIntentParams: Stripe.PaymentIntentCreateParams = { amount, currency: paymentConfig.defaultCurrency, - payment_method_types: [ - 'card', - /* 'grabpay', 'paynow'*/ - ], + // determine payment methods available based on stripe settings + automatic_payment_methods: { + enabled: true, + }, description: paymentReceiptDescription, receipt_email: paymentReceiptEmail, metadata, From 082583a8e8dca073339591701d30b8c9f4140d14 Mon Sep 17 00:00:00 2001 From: Lin Huiqing <37061143+LinHuiqing@users.noreply.github.com> Date: Thu, 11 May 2023 11:04:17 +0800 Subject: [PATCH 3/4] feat: control feature flags through DB (#6286) * chore: correct date in add-payment-flag filename * feat: get global beta flags from db * chore: correct date in multi-language-stats filename * fix: allow payment requests if global beta enabled * refactor: move global beta flags to admin form * refactor: set up beta flag shared constants * chore: update comments for global beta flag code * fix: set global payment beta flag to false * refactor: global beta schema definition into compile * refactor: rename flagEnabled to enabled * refactor: reusable displayPayments constant * fix: return false instead of throw err for getGlobalBeta - can decide to move returning false to service level in the future if we decide otherwise * refactor: rename global beta to feature flags * refactor: rename global beta to feature flag for script * refactor: rename beta-flags as feature-flags in shared constants * refactor: separate endpoint for feature flags * feat: get all enabled feature flags instead * refactor: rm feature flag check in verifyUserBetaFlag * fix: remove unnecessary params in handleGetEnabledFlags - update error messages for controllers using getEnabledFeatureFlags too * chore: update db err message for getEnabledFlags * chore: feature flags nomenclature * chore: add TODO for local caching system * chore: link to comment instead of PR * chore: update new collection name --- .../FieldListDrawer/FieldListDrawer.tsx | 9 ++- .../admin-form/settings/SettingsPage.tsx | 11 ++- .../src/features/feature-flags/queries.ts | 13 ++++ frontend/src/services/FeatureFlagService.ts | 7 ++ .../.env.template | 0 .../index.js | 0 .../package.json | 0 .../readme.md | 0 .../add-payment-flag.js | 0 .../add-feature-flag-collection.js | 20 +++++ shared/constants/feature-flags.ts | 3 + shared/constants/index.ts | 1 + src/app/models/feature_flag.server.model.ts | 46 +++++++++++ .../feature-flags/feature-flags.controller.ts | 38 +++++++++ .../feature-flags/feature-flags.service.ts | 25 ++++++ .../admin-form.payments.controller.ts | 78 +++++++++++++++---- .../modules/frontend/frontend.controller.ts | 2 +- .../v3/feature-flags/feature-flags.routes.ts | 12 +++ src/app/routes/api/v3/feature-flags/index.ts | 1 + src/app/routes/api/v3/v3.routes.ts | 2 + src/types/feature_flag.ts | 10 +++ 21 files changed, 260 insertions(+), 18 deletions(-) create mode 100644 frontend/src/features/feature-flags/queries.ts create mode 100644 frontend/src/services/FeatureFlagService.ts rename scripts/{202202117_multi-language-stats => 20220217_multi-language-stats}/.env.template (100%) rename scripts/{202202117_multi-language-stats => 20220217_multi-language-stats}/index.js (100%) rename scripts/{202202117_multi-language-stats => 20220217_multi-language-stats}/package.json (100%) rename scripts/{202202117_multi-language-stats => 20220217_multi-language-stats}/readme.md (100%) rename scripts/{20232202_add-payments-flag => 20230222_add-payments-flag}/add-payment-flag.js (100%) create mode 100644 scripts/20230508_add-feature-flag/add-feature-flag-collection.js create mode 100644 shared/constants/feature-flags.ts create mode 100644 src/app/models/feature_flag.server.model.ts create mode 100644 src/app/modules/feature-flags/feature-flags.controller.ts create mode 100644 src/app/modules/feature-flags/feature-flags.service.ts create mode 100644 src/app/routes/api/v3/feature-flags/feature-flags.routes.ts create mode 100644 src/app/routes/api/v3/feature-flags/index.ts create mode 100644 src/types/feature_flag.ts diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/FieldListDrawer.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/FieldListDrawer.tsx index 6f90eebcd2..0ae3b14746 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/FieldListDrawer.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/FieldListDrawer.tsx @@ -9,9 +9,12 @@ import { Text, } from '@chakra-ui/react' +import { featureFlags } from '~shared/constants' + import { Tab } from '~components/Tabs' import { useCreatePageSidebar } from '~features/admin-form/create/common/CreatePageSidebarContext' +import { useFeatureFlags } from '~features/feature-flags/queries' import { useUser } from '~features/user/queries' import { useCreateTabForm } from '../../../builder-and-design/useCreateTabForm' @@ -28,7 +31,11 @@ export const FieldListDrawer = (): JSX.Element => { const { isLoading } = useCreateTabForm() const { user } = useUser() - const displayPayments = user?.betaFlags?.payment + const { data: flags } = useFeatureFlags() + + const displayPayments = + user?.betaFlags?.payment || flags?.has(featureFlags.payment) + return ( { const { formId } = useParams() const { user } = useUser() + const { data: flags } = useFeatureFlags() if (!formId) throw new Error('No formId provided') @@ -43,6 +47,9 @@ export const SettingsPage = (): JSX.Element => { const { ref, onMouseDown } = useDraggable() + const displayPayments = + user?.betaFlags?.payment || flags?.has(featureFlags.payment) + return ( { - {user?.betaFlags?.payment && ( + {displayPayments && ( )} @@ -106,7 +113,7 @@ export const SettingsPage = (): JSX.Element => { - {user?.betaFlags?.payment && ( + {displayPayments && ( diff --git a/frontend/src/features/feature-flags/queries.ts b/frontend/src/features/feature-flags/queries.ts new file mode 100644 index 0000000000..a10e2e3fd2 --- /dev/null +++ b/frontend/src/features/feature-flags/queries.ts @@ -0,0 +1,13 @@ +import { useQuery, UseQueryResult } from 'react-query' + +import { getEnabledFeatureFlags } from '~services/FeatureFlagService' + +export const featureFlagsKeys = { + base: ['feature-flags'] as const, +} + +// TODO: Add local caching system with interval based refresh +// Refer to https://github.com/opengovsg/FormSG/pull/6286#discussion_r1190529370 +export const useFeatureFlags = (): UseQueryResult> => { + return useQuery(featureFlagsKeys.base, () => getEnabledFeatureFlags()) +} diff --git a/frontend/src/services/FeatureFlagService.ts b/frontend/src/services/FeatureFlagService.ts new file mode 100644 index 0000000000..9ce5c32cd1 --- /dev/null +++ b/frontend/src/services/FeatureFlagService.ts @@ -0,0 +1,7 @@ +import { ApiService } from './ApiService' + +export const getEnabledFeatureFlags = async (): Promise> => { + return ApiService.get('/feature-flags/enabled').then( + ({ data }) => new Set(data), + ) +} diff --git a/scripts/202202117_multi-language-stats/.env.template b/scripts/20220217_multi-language-stats/.env.template similarity index 100% rename from scripts/202202117_multi-language-stats/.env.template rename to scripts/20220217_multi-language-stats/.env.template diff --git a/scripts/202202117_multi-language-stats/index.js b/scripts/20220217_multi-language-stats/index.js similarity index 100% rename from scripts/202202117_multi-language-stats/index.js rename to scripts/20220217_multi-language-stats/index.js diff --git a/scripts/202202117_multi-language-stats/package.json b/scripts/20220217_multi-language-stats/package.json similarity index 100% rename from scripts/202202117_multi-language-stats/package.json rename to scripts/20220217_multi-language-stats/package.json diff --git a/scripts/202202117_multi-language-stats/readme.md b/scripts/20220217_multi-language-stats/readme.md similarity index 100% rename from scripts/202202117_multi-language-stats/readme.md rename to scripts/20220217_multi-language-stats/readme.md diff --git a/scripts/20232202_add-payments-flag/add-payment-flag.js b/scripts/20230222_add-payments-flag/add-payment-flag.js similarity index 100% rename from scripts/20232202_add-payments-flag/add-payment-flag.js rename to scripts/20230222_add-payments-flag/add-payment-flag.js diff --git a/scripts/20230508_add-feature-flag/add-feature-flag-collection.js b/scripts/20230508_add-feature-flag/add-feature-flag-collection.js new file mode 100644 index 0000000000..a75dd1834d --- /dev/null +++ b/scripts/20230508_add-feature-flag/add-feature-flag-collection.js @@ -0,0 +1,20 @@ +/* eslint-disable */ + +/* +Add global beta collection to store global beta flags +*/ + +// Create globalBeta collection +db.createCollection('featureflags') + +// Add payment beta flag and set enabled to true or false +// Upsert if not exist +db.featureflags.update( + { name: 'payment' }, + { + $setOnInsert: { + enabled: false, + }, + }, + { upsert: true }, +) diff --git a/shared/constants/feature-flags.ts b/shared/constants/feature-flags.ts new file mode 100644 index 0000000000..1d79797a55 --- /dev/null +++ b/shared/constants/feature-flags.ts @@ -0,0 +1,3 @@ +export const featureFlags = { + payment: 'payment' as const, +} diff --git a/shared/constants/index.ts b/shared/constants/index.ts index 1b3d71a553..31903d698d 100644 --- a/shared/constants/index.ts +++ b/shared/constants/index.ts @@ -1,3 +1,4 @@ export * from './file' export * from './form' export * from './links' +export * from './feature-flags' diff --git a/src/app/models/feature_flag.server.model.ts b/src/app/models/feature_flag.server.model.ts new file mode 100644 index 0000000000..5b85cb31b5 --- /dev/null +++ b/src/app/models/feature_flag.server.model.ts @@ -0,0 +1,46 @@ +import { Mongoose, Schema } from 'mongoose' + +import { IFeatureFlagModel, IFeatureFlagSchema } from 'src/types/feature_flag' + +const FEATURE_FLAG_SCHEMA_ID = 'featureFlag' + +const compileFeatureFlagModel = (db: Mongoose): IFeatureFlagModel => { + const FeatureFlagSchema = new Schema({ + name: { + type: Schema.Types.String, + required: true, + }, + enabled: { + type: Schema.Types.Boolean, + required: true, + default: false, + }, + }) + + // Statics + /** + * Find all enabled flags + */ + FeatureFlagSchema.statics.enabledFlags = async function () { + return await this.find({ enabled: true }).exec() + } + + const FeatureFlagModel = db.model( + FEATURE_FLAG_SCHEMA_ID, + FeatureFlagSchema, + ) + + return FeatureFlagModel +} + +const getFeatureFlagModel = (db: Mongoose): IFeatureFlagModel => { + try { + return db.model( + FEATURE_FLAG_SCHEMA_ID, + ) + } catch { + return compileFeatureFlagModel(db) + } +} + +export default getFeatureFlagModel diff --git a/src/app/modules/feature-flags/feature-flags.controller.ts b/src/app/modules/feature-flags/feature-flags.controller.ts new file mode 100644 index 0000000000..c1b233191d --- /dev/null +++ b/src/app/modules/feature-flags/feature-flags.controller.ts @@ -0,0 +1,38 @@ +import { StatusCodes } from 'http-status-codes' + +import { ErrorDto } from '../../../../shared/types' +import { createLoggerWithLabel } from '../../config/logger' +import { createReqMeta } from '../../utils/request' +import { ControllerHandler } from '../core/core.types' + +import * as FeatureFlagService from './feature-flags.service' + +const logger = createLoggerWithLabel(module) + +/** + * Handler for GET /feature-flags/enabled endpoint. + * @returns whether feature flag has been enabled. + */ +export const handleGetEnabledFlags: ControllerHandler< + never, + // TODO: stricter typing to restrict typing to flag values in shared/constants + string[] | ErrorDto +> = (req, res) => { + // If getFeatureFlag throws a DatabaseError, we want to log it, but respond + // to the client as if the flag is not found. + return FeatureFlagService.getEnabledFlags() + .map((result) => { + return res.status(StatusCodes.OK).json(result) + }) + .mapErr((error) => { + logger.error({ + message: `Failed to retrieve enabled feature flags`, + meta: { + action: 'handleGetFeatureFlag', + ...createReqMeta(req), + }, + error, + }) + return res.status(StatusCodes.OK).json([]) + }) +} diff --git a/src/app/modules/feature-flags/feature-flags.service.ts b/src/app/modules/feature-flags/feature-flags.service.ts new file mode 100644 index 0000000000..063bf9d619 --- /dev/null +++ b/src/app/modules/feature-flags/feature-flags.service.ts @@ -0,0 +1,25 @@ +import mongoose from 'mongoose' +import { okAsync, ResultAsync } from 'neverthrow' + +import { createLoggerWithLabel } from '../../config/logger' +import getFeatureFlagModel from '../../models/feature_flag.server.model' +import { DatabaseError } from '../core/core.errors' + +const logger = createLoggerWithLabel(module) +const FeatureFlagModel = getFeatureFlagModel(mongoose) + +export const getEnabledFlags = (): ResultAsync => { + return ResultAsync.fromPromise(FeatureFlagModel.enabledFlags(), (error) => { + logger.error({ + message: 'Database error when getting feature flag status', + meta: { + action: 'getEnabledFlags', + }, + error, + }) + + return new DatabaseError('Unable to fetch enabled feature flags.') + }).andThen((enabledFlagsDocs) => + okAsync(enabledFlagsDocs.map((doc) => doc.name)), + ) +} diff --git a/src/app/modules/form/admin-form/admin-form.payments.controller.ts b/src/app/modules/form/admin-form/admin-form.payments.controller.ts index 7bc15e728a..b89852d26b 100644 --- a/src/app/modules/form/admin-form/admin-form.payments.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.payments.controller.ts @@ -5,6 +5,7 @@ import { err, ok } from 'neverthrow' import { IEncryptedFormDocument } from 'src/types' +import { featureFlags } from '../../../../../shared/constants' import { ErrorDto, PaymentChannel, @@ -15,6 +16,7 @@ import { createReqMeta } from '../../../utils/request' import { getFormAfterPermissionChecks } from '../../auth/auth.service' import * as AuthService from '../../auth/auth.service' import { ControllerHandler } from '../../core/core.types' +import * as FeatureFlagService from '../../feature-flags/feature-flags.service' import { getStripeOauthUrl, unlinkStripeAccountFromForm, @@ -50,11 +52,38 @@ export const handleConnectAccount: ControllerHandler<{ const { formId } = req.params const sessionUserId = (req.session as AuthedSessionData).user._id + const logMeta = { + action: 'handleConnectAccount', + ...createReqMeta(req), + } + + // If getFeatureFlag throws a DatabaseError, we want to log it, but respond + // to the client as if the flag is not found. + const featureFlagsListResult = await FeatureFlagService.getEnabledFlags() + + let featureFlagEnabled = false + + if (featureFlagsListResult.isErr()) { + logger.error({ + message: 'Error occurred whilst retrieving enabled feature flags', + meta: logMeta, + error: featureFlagsListResult.error, + }) + } else { + featureFlagEnabled = featureFlagsListResult.value.includes( + featureFlags.payment, + ) + } + // Step 1: Retrieve currently logged in user. return ( getPopulatedUserById(sessionUserId) // Step 2: Check if user has 'payment' betaflag - .andThen((user) => verifyUserBetaflag(user, 'payment')) + .andThen((user) => + featureFlagEnabled + ? ok(user) + : verifyUserBetaflag(user, featureFlags.payment), + ) .andThen((user) => // Step 3: Retrieve form with write permission check. getFormAfterPermissionChecks({ @@ -76,10 +105,7 @@ export const handleConnectAccount: ControllerHandler<{ .mapErr((error) => { logger.error({ message: 'Error connecting admin form payment account', - meta: { - action: 'handleConnectAccount', - ...createReqMeta(req), - }, + meta: logMeta, error, }) @@ -212,15 +238,45 @@ export const _handleUpdatePayments: ControllerHandler< { formId: string }, IEncryptedFormDocument['payments_field'] | ErrorDto, PaymentsUpdateDto -> = (req, res) => { +> = async (req, res) => { const { formId } = req.params const sessionUserId = (req.session as AuthedSessionData).user._id + const logMeta = { + action: '_handleUpdatePayments', + ...createReqMeta(req), + userId: sessionUserId, + formId, + body: req.body, + } + + // If getFeatureFlag throws a DatabaseError, we want to log it, but respond + // to the client as if the flag is not found. + const featureFlagsListResult = await FeatureFlagService.getEnabledFlags() + + let featureFlagEnabled = false + + if (featureFlagsListResult.isErr()) { + logger.error({ + message: 'Error occurred whilst retrieving enabled feature flags', + meta: logMeta, + error: featureFlagsListResult.error, + }) + } else { + featureFlagEnabled = featureFlagsListResult.value.includes( + featureFlags.payment, + ) + } + // Step 1: Retrieve currently logged in user. return ( UserService.getPopulatedUserById(sessionUserId) // Step 2: Check if user has 'payment' betaflag - .andThen((user) => verifyUserBetaflag(user, 'payment')) + .andThen((user) => + featureFlagEnabled + ? ok(user) + : verifyUserBetaflag(user, featureFlags.payment), + ) .andThen((user) => // Step 2: Retrieve form with write permission check. AuthService.getFormAfterPermissionChecks({ @@ -244,13 +300,7 @@ export const _handleUpdatePayments: ControllerHandler< .mapErr((error) => { logger.error({ message: 'Error occurred when updating payments', - meta: { - action: '_handleUpdatePayments', - ...createReqMeta(req), - userId: sessionUserId, - formId, - body: req.body, - }, + meta: logMeta, error, }) const { errorMessage, statusCode } = mapRouteError(error) diff --git a/src/app/modules/frontend/frontend.controller.ts b/src/app/modules/frontend/frontend.controller.ts index cb2165cdbe..13165884b3 100644 --- a/src/app/modules/frontend/frontend.controller.ts +++ b/src/app/modules/frontend/frontend.controller.ts @@ -80,7 +80,7 @@ export const addEnvVarData: ControllerHandler = ( } /** - * Handler for GET /frontend/env endpoint. + * Handler for GET /client/env endpoint. * @returns the environment variables needed to hydrate the frontend. */ export const handleGetEnvironment: ControllerHandler = ( diff --git a/src/app/routes/api/v3/feature-flags/feature-flags.routes.ts b/src/app/routes/api/v3/feature-flags/feature-flags.routes.ts new file mode 100644 index 0000000000..cdcfb9d6e5 --- /dev/null +++ b/src/app/routes/api/v3/feature-flags/feature-flags.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express' + +import * as FeatureFlagsController from '../../../../modules/feature-flags/feature-flags.controller' + +export const FeatureFlagsRouter = Router() + +/** + * Retrieve the environment variables for the frontend. + * @route GET /api/v3/feature-flags/enabled + * @return 200 with list of enabled flags' documents + */ +FeatureFlagsRouter.get('/enabled', FeatureFlagsController.handleGetEnabledFlags) diff --git a/src/app/routes/api/v3/feature-flags/index.ts b/src/app/routes/api/v3/feature-flags/index.ts new file mode 100644 index 0000000000..6bace0ad55 --- /dev/null +++ b/src/app/routes/api/v3/feature-flags/index.ts @@ -0,0 +1 @@ +export { FeatureFlagsRouter } from './feature-flags.routes' diff --git a/src/app/routes/api/v3/v3.routes.ts b/src/app/routes/api/v3/v3.routes.ts index d44ba9c2da..de12d67c05 100644 --- a/src/app/routes/api/v3/v3.routes.ts +++ b/src/app/routes/api/v3/v3.routes.ts @@ -6,6 +6,7 @@ import { AuthRouter } from './auth' import { BillingsRouter } from './billings' import { ClientRouter } from './client' import { CorppassOidcRouter } from './corppass' +import { FeatureFlagsRouter } from './feature-flags' import { PublicFormsRouter } from './forms' import { NotificationsRouter } from './notifications' import { PaymentsRouter } from './payments' @@ -25,3 +26,4 @@ V3Router.use('/forms', PublicFormsRouter) V3Router.use('/singpass', SingpassOidcRouter) V3Router.use('/corppass', CorppassOidcRouter) V3Router.use('/payments', PaymentsRouter) +V3Router.use('/feature-flags', FeatureFlagsRouter) diff --git a/src/types/feature_flag.ts b/src/types/feature_flag.ts new file mode 100644 index 0000000000..3615eb242f --- /dev/null +++ b/src/types/feature_flag.ts @@ -0,0 +1,10 @@ +import { Model } from 'mongoose' + +export interface IFeatureFlagSchema { + name: string + enabled: boolean +} + +export interface IFeatureFlagModel extends Model { + enabledFlags: () => Promise +} From e0b174b55ccdbdae3c3754cce0540d81d3bc3dc9 Mon Sep 17 00:00:00 2001 From: wanlingt Date: Thu, 11 May 2023 11:28:29 +0800 Subject: [PATCH 4/4] chore: bump version to v6.49.0 --- CHANGELOG.md | 11 +++++++++++ frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 419ea4441d..3fcccaa214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,25 @@ 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.49.0](https://github.com/opengovsg/FormSG/compare/v6.48.0...v6.49.0) + +- feat: control feature flags through DB [`#6286`](https://github.com/opengovsg/FormSG/pull/6286) +- build: merge release v6.48.0 to develop [`#6295`](https://github.com/opengovsg/FormSG/pull/6295) +- feat: control payment methods through stripe dashboard [`#6272`](https://github.com/opengovsg/FormSG/pull/6272) +- feat: add business field mutation to form-level [`#6236`](https://github.com/opengovsg/FormSG/pull/6236) +- build: release v6.48.0 [`#6291`](https://github.com/opengovsg/FormSG/pull/6291) + #### [v6.48.0](https://github.com/opengovsg/FormSG/compare/v6.47.0...v6.48.0) +> 9 May 2023 + - feat: update sms copy to remove link (policy) [`#6289`](https://github.com/opengovsg/FormSG/pull/6289) - chore(deps-dev): bump @typescript-eslint/parser from 5.59.2 to 5.59.5 in /shared [`#6287`](https://github.com/opengovsg/FormSG/pull/6287) - build: merge release v6.47.0 to develop [`#6281`](https://github.com/opengovsg/FormSG/pull/6281) - fix: add missing env var in docker_compose.yml [`#6284`](https://github.com/opengovsg/FormSG/pull/6284) - build: release v6.47.0 [`#6279`](https://github.com/opengovsg/FormSG/pull/6279) - chore: bump version to v6.47.0 [`002b8b6`](https://github.com/opengovsg/FormSG/commit/002b8b6b66c6cc199921311a2ea1800644bcc332) +- chore: bump version to v6.48.0 [`074746f`](https://github.com/opengovsg/FormSG/commit/074746f55303ab1ac2fb7e398b04bf91451fa9a0) #### [v6.47.0](https://github.com/opengovsg/FormSG/compare/v6.46.0...v6.47.0) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 247e33b92a..d48923fb48 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.48.0", + "version": "6.49.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.48.0", + "version": "6.49.0", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^1.8.6", diff --git a/frontend/package.json b/frontend/package.json index 5c57fbf328..58933ccb30 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.48.0", + "version": "6.49.0", "homepage": ".", "private": true, "dependencies": { diff --git a/package-lock.json b/package-lock.json index 60b5ace912..92f5686ce9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.48.0", + "version": "6.49.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.48.0", + "version": "6.49.0", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.276.0", diff --git a/package.json b/package.json index 090cead1ea..aac7ba8148 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.48.0", + "version": "6.49.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG "