diff --git a/src/client/actions/user/index.ts b/src/client/actions/user/index.ts index 3048962b8..f746feb53 100644 --- a/src/client/actions/user/index.ts +++ b/src/client/actions/user/index.ts @@ -16,6 +16,8 @@ import { RESET_USER_STATE, ResetUserStateAction, SET_CREATE_SHORT_LINK_ERROR, + SET_EDITED_CONTACT_EMAIL, + SET_EDITED_DESCRIPTION, SET_EDITED_LONG_URL, SET_IS_UPLOADING, SET_LAST_CREATED_LINK, @@ -26,6 +28,8 @@ import { SET_URL_FILTER, SET_URL_TABLE_CONFIG, SetCreateShortLinkErrorAction, + SetEditedContactEmailAction, + SetEditedDescriptionAction, SetEditedLongUrlAction, SetIsUploadingAction, SetLastCreatedLinkAction, @@ -90,6 +94,28 @@ const setIsUploading: (payload: boolean) => SetIsUploadingAction = ( payload, }) +const setEditedContactEmail: ( + shortUrl: string, + editedContactEmail: string, +) => SetEditedContactEmailAction = (shortUrl, editedContactEmail) => ({ + type: SET_EDITED_CONTACT_EMAIL, + payload: { + shortUrl, + editedContactEmail, + }, +}) + +const setEditedDescription: ( + shortUrl: string, + editedDescription: string, +) => SetEditedDescriptionAction = (shortUrl, editedDescription) => ({ + type: SET_EDITED_DESCRIPTION, + payload: { + shortUrl, + editedDescription, + }, +}) + const setCreateShortLinkError: ( payload: string | null, ) => SetCreateShortLinkErrorAction = (payload) => ({ @@ -227,11 +253,12 @@ const getUrlsForUser = (): ThunkAction< if (isOk) { json.urls.forEach((url: UrlType) => { - url.createdAt = moment(url.createdAt) // eslint-disable-line no-param-reassign - .tz('Singapore') - .format('D MMM YYYY') - // eslint-disable-next-line no-param-reassign + /* eslint-disable no-param-reassign */ + url.createdAt = moment(url.createdAt).tz('Singapore').format('D MMM YYYY') url.editedLongUrl = removeHttpsProtocol(url.longUrl) + url.editedContactEmail = url.contactEmail + url.editedDescription = url.description + /* eslint-enable no-param-reassign */ }) dispatch(isGetUrlsForUserSuccess(json.urls)) dispatch(updateUrlCount(json.count)) @@ -288,6 +315,45 @@ const updateLongUrl = (shortUrl: string, longUrl: string) => ( }) } +// API call to update description and contact email +const updateUrlInformation = (shortUrl: string) => ( + dispatch: ThunkDispatch< + GoGovReduxState, + void, + SetErrorMessageAction | SetSuccessMessageAction + >, + getState: GetReduxState, +) => { + const { user } = getState() + const url = user.urls.filter( + (urlToCheck) => urlToCheck.shortUrl === shortUrl, + )[0] + if (!url) { + dispatch( + rootActions.setErrorMessage('Url not found.'), + ) + return null + } + return patch('/api/user/url', { + contactEmail: url.editedContactEmail ? url.editedContactEmail : null, + description: url.editedDescription, + shortUrl, + }).then((response) => { + if (response.ok) { + dispatch(getUrlsForUser()) + dispatch( + rootActions.setSuccessMessage('URL is updated.'), + ) + return null + } + + return response.json().then((json) => { + dispatch(rootActions.setErrorMessage(json.message)) + return null + }) + }) +} + // API call to replace file const replaceFile = ( shortUrl: string, @@ -553,4 +619,7 @@ export default { setCreateShortLinkError, setUrlFilter, replaceFile, + setEditedContactEmail, + setEditedDescription, + updateUrlInformation, } diff --git a/src/client/actions/user/types.ts b/src/client/actions/user/types.ts index 203f52859..9296b8eca 100644 --- a/src/client/actions/user/types.ts +++ b/src/client/actions/user/types.ts @@ -25,6 +25,24 @@ export const SET_IS_UPLOADING = 'SET_IS_UPLOADING' export const SET_UPLOAD_FILE_ERROR = 'SET_UPLOAD_FILE_ERROR' export const SET_CREATE_SHORT_LINK_ERROR = 'SET_CREATE_SHORT_LINK_ERROR' export const SET_LAST_CREATED_LINK = 'SET_LAST_CREATED_LINK' +export const SET_EDITED_CONTACT_EMAIL = 'SET_EDITED_CONTACT_EMAIL' +export const SET_EDITED_DESCRIPTION = 'SET_EDITED_DESCRIPTION' + +export type SetEditedContactEmailAction = { + type: typeof SET_EDITED_CONTACT_EMAIL + payload: { + shortUrl: string + editedContactEmail: string + } +} + +export type SetEditedDescriptionAction = { + type: typeof SET_EDITED_DESCRIPTION + payload: { + shortUrl: string + editedDescription: string + } +} export type SetLastCreatedLinkAction = { type: typeof SET_LAST_CREATED_LINK @@ -143,3 +161,5 @@ export type UserActionType = | SetUploadFileErrorAction | SetLastCreatedLinkAction | SetUrlFilterAction + | SetEditedContactEmailAction + | SetEditedDescriptionAction diff --git a/src/client/components/CollapsibleMessage/index.tsx b/src/client/components/CollapsibleMessage/index.tsx index 93c9206db..1903b4968 100644 --- a/src/client/components/CollapsibleMessage/index.tsx +++ b/src/client/components/CollapsibleMessage/index.tsx @@ -7,11 +7,12 @@ const CollapsibleMessage: FunctionComponent = ({ type, visible, children, + timeout, position = CollapsibleMessagePosition.Static, }) => { const classes = useStyles({ type, position }) return ( - +
{children}
) diff --git a/src/client/components/CollapsibleMessage/types.ts b/src/client/components/CollapsibleMessage/types.ts index 179ca93fd..63d9d050d 100644 --- a/src/client/components/CollapsibleMessage/types.ts +++ b/src/client/components/CollapsibleMessage/types.ts @@ -1,3 +1,5 @@ +import { CollapseProps } from '@material-ui/core' + export enum CollapsibleMessageType { Error, Success, @@ -7,7 +9,7 @@ export type CollapsibleMessageProps = { type: CollapsibleMessageType visible: boolean position?: CollapsibleMessagePosition -} +} & Pick export type CollapsibleMessageStyles = { type: CollapsibleMessageType diff --git a/src/client/components/UserPage/Drawer/ControlPanel/index.tsx b/src/client/components/UserPage/Drawer/ControlPanel/index.tsx index b12f5ae79..2ec19f616 100644 --- a/src/client/components/UserPage/Drawer/ControlPanel/index.tsx +++ b/src/client/components/UserPage/Drawer/ControlPanel/index.tsx @@ -10,6 +10,7 @@ import { useTheme, useMediaQuery, CircularProgress, + Typography, } from '@material-ui/core' import DrawerActions from './util/reducers' @@ -33,6 +34,8 @@ import { CollapsibleMessageType, CollapsibleMessagePosition, } from '../../../CollapsibleMessage/types' +import { LINK_DESCRIPTION_MAX_LENGTH } from '../../../../../shared/constants' +import i18next from 'i18next' const useStyles = makeStyles((theme) => createStyles({ @@ -66,6 +69,22 @@ const useStyles = makeStyles((theme) => marginBottom: 68, }, }, + dividerAnalytics: { + marginTop: 50, + marginBottom: 50, + [theme.breakpoints.up('md')]: { + marginTop: 50, + marginBottom: 68, + }, + }, + dividerInformation: { + marginTop: 50, + marginBottom: 50, + [theme.breakpoints.up('md')]: { + marginTop: 73, + marginBottom: 50, + }, + }, activeText: { color: '#6d9067', }, @@ -79,10 +98,14 @@ const useStyles = makeStyles((theme) => width: '14px', verticalAlign: 'middle', }, - ownershipTooltip: { - margin: theme.spacing(1.5, 1, 1.5, 1), + drawerTooltip: { + // margin: theme.spacing(1.5, 1, 1.5, 1), whiteSpace: 'nowrap', maxWidth: 'unset', + [theme.breakpoints.up('md')]: { + marginTop: '-12px', + padding: '16px', + }, }, topBar: { width: '100%', @@ -108,6 +131,24 @@ const useStyles = makeStyles((theme) => marginBottom: 0, }, }, + characterCount: { + marginLeft: 2, + marginTop: 9, + }, + linkInformationHeader: { + marginBottom: theme.spacing(1.25), + }, + linkInformationDesc: { + marginBottom: theme.spacing(3), + fontWeight: 400, + }, + saveLinkInformationButtonWrapper: { + display: 'flex', + justifyContent: 'flex-end', + [theme.breakpoints.up('md')]: { + paddingTop: 9, + }, + }, }), ) @@ -128,18 +169,29 @@ export default function ControlPanel() { modalDispatch({ type: DrawerActions.setUploadFileError, payload: error }) // Fetch short link state and dispatches from redux store through our helper hook. - const { shortLinkState, shortLinkDispatch, isUploading } = useShortLink( - drawerStates.relevantShortLink!, - ) + const { + shortLinkState, + shortLinkDispatch, + isUploading, + emailValidator, + } = useShortLink(drawerStates.relevantShortLink!) // Manage values in our text fields. const originalLongUrl = removeHttpsProtocol(shortLinkState?.longUrl || '') const editedLongUrl = shortLinkState?.editedLongUrl || '' + const editedContactEmail = shortLinkState?.editedContactEmail || '' + const editedDescription = shortLinkState?.editedDescription || '' const [pendingOwner, setPendingOwner] = useState('') + const originalDescription = shortLinkState?.description || '' + const originalContactEmail = shortLinkState?.contactEmail || '' + const isContactEmailValid = + !editedContactEmail || emailValidator.match(editedContactEmail) // Disposes any current unsaved changes and closes the modal. const handleClose = () => { shortLinkDispatch?.setEditLongUrl(originalLongUrl) + shortLinkDispatch?.setEditDescription(originalDescription) + shortLinkDispatch?.setEditContactEmail(originalContactEmail) setPendingOwner('') modalDispatch({ type: DrawerActions.closeControlPanel }) } @@ -165,7 +217,7 @@ export default function ControlPanel() { title="Links can only be transferred to an existing Go.gov.sg user" arrow placement="top" - classes={{ tooltip: classes.ownershipTooltip }} + classes={{ tooltip: classes.drawerTooltip }} > ) + const contactEmailHelp = ( + <> + Contact email{' '} + + Contact help + + + ) + + const linkDescriptionHelp = ( + <> + Link description{' '} + + Description help + + + ) + const replaceFileHelp = (
Original file{' '} @@ -184,7 +274,7 @@ export default function ControlPanel() { title="Original file will be replaced after you select file. Maximum file size is 10mb." arrow placement="top" - classes={{ tooltip: classes.ownershipTooltip }} + classes={{ tooltip: classes.drawerTooltip }} > - + + + Link information + + + The information you enter below will be displayed on our Go Search + page (coming soon), and the error page if users are unable to access + your short link. + + + shortLinkDispatch?.setEditContactEmail(event.target.value) + } + placeholder="" + helperText={ + isContactEmailValid + ? '' + : `This doesn't look like a valid ${i18next.t( + 'general.emailDomain', + )} email.` + } + error={!isContactEmailValid} + /> + } + trailing={<>} + wrapTrailing={isMobileView} + trailingPosition={TrailingPosition.none} + /> + + + shortLinkDispatch?.setEditDescription( + event.target.value.replace(/(\r\n|\n|\r)/gm, ''), + ) + } + error={editedDescription.length > LINK_DESCRIPTION_MAX_LENGTH} + placeholder="" + helperText={ + editedDescription.length <= LINK_DESCRIPTION_MAX_LENGTH + ? `${editedDescription.length}/${LINK_DESCRIPTION_MAX_LENGTH}` + : undefined + } + multiline + rows={2} + rowsMax={isMobileView ? 5 : undefined} + FormHelperTextProps={{ className: classes.characterCount }} + /> + LINK_DESCRIPTION_MAX_LENGTH + } + position={CollapsibleMessagePosition.Static} + timeout={0} + > + {`${editedDescription.length}/200`} + + + } + trailing={<>} + wrapTrailing={isMobileView} + trailingPosition={TrailingPosition.none} + /> +
+ LINK_DESCRIPTION_MAX_LENGTH || + (editedContactEmail === originalContactEmail && + editedDescription === originalDescription) || + !isContactEmailValid + } + fullWidth={isMobileView} + variant={isMobileView ? 'contained' : 'outlined'} + onClick={shortLinkDispatch?.applyEditInformation} + > + Save + +
+ diff --git a/src/client/components/UserPage/Drawer/ControlPanel/util/shortlink.ts b/src/client/components/UserPage/Drawer/ControlPanel/util/shortlink.ts index 4e1509183..ca4c8af71 100644 --- a/src/client/components/UserPage/Drawer/ControlPanel/util/shortlink.ts +++ b/src/client/components/UserPage/Drawer/ControlPanel/util/shortlink.ts @@ -1,5 +1,6 @@ import { useDispatch, useSelector } from 'react-redux' +import { IMinimatch } from 'minimatch' import userActions from '../../../../../actions/user' import { isValidLongUrl } from '../../../../../../shared/util/validation' import { UrlType } from '../../../../../reducers/user/types' @@ -26,6 +27,9 @@ export default function useShortLink(shortLink: string) { const isUploading = useSelector( (state) => state.user.isUploading, ) + const emailValidator = useSelector( + (state) => state.login.emailValidator, + ) const dispatch = useDispatch() const dispatchOptions = { toggleStatus: () => @@ -33,12 +37,21 @@ export default function useShortLink(shortLink: string) { setEditLongUrl: (editedUrl: string) => { dispatch(userActions.setEditedLongUrl(shortLink, editedUrl)) }, + setEditDescription: (editedDesc: string) => { + dispatch(userActions.setEditedDescription(shortLink, editedDesc)) + }, + setEditContactEmail: (editedContactEmail: string) => { + dispatch(userActions.setEditedContactEmail(shortLink, editedContactEmail)) + }, applyEditLongUrl: (editedUrl: string) => { if (!isValidLongUrl(editedUrl)) { throw new Error('Attempt to save an invalid long url.') } dispatch(userActions.updateLongUrl(shortLink, editedUrl)) }, + applyEditInformation: () => { + dispatch(userActions.updateUrlInformation(shortLink)) + }, applyNewOwner: (newOwner: string, onSuccess: () => void) => { dispatch(userActions.transferOwnership(shortLink, newOwner, onSuccess)) }, @@ -54,5 +67,6 @@ export default function useShortLink(shortLink: string) { shortLinkState: shortLink ? urlState : undefined, shortLinkDispatch: shortLink ? dispatchOptions : undefined, isUploading, + emailValidator, } } diff --git a/src/client/components/UserPage/Drawer/ControlPanel/widgets/ConfigOption.tsx b/src/client/components/UserPage/Drawer/ControlPanel/widgets/ConfigOption.tsx index c1addaa0a..9bfd96b3c 100644 --- a/src/client/components/UserPage/Drawer/ControlPanel/widgets/ConfigOption.tsx +++ b/src/client/components/UserPage/Drawer/ControlPanel/widgets/ConfigOption.tsx @@ -19,12 +19,14 @@ const useStyles = makeStyles((theme) => }, leadingContainer: { flex: 1, - marginBottom: 8, + marginBottom: (props: StylesProps) => + props.trailingPosition === TrailingPosition.none ? 0 : theme.spacing(3), flexBasis: '100%', [theme.breakpoints.up('md')]: { flexBasis: 0, - marginBottom: 0, - marginRight: 19, + marginBottom: () => 0, // Function can only be overwritten by another function + marginRight: (props: StylesProps) => + props.trailingPosition === TrailingPosition.none ? 0 : 19, }, position: 'relative', }, @@ -42,6 +44,7 @@ export enum TrailingPosition { start, center, end, + none, } type ConfigOptionProps = { diff --git a/src/client/components/UserPage/Drawer/ControlPanel/widgets/DrawerTextField.tsx b/src/client/components/UserPage/Drawer/ControlPanel/widgets/DrawerTextField.tsx index 6710b5200..a4fc5aafd 100644 --- a/src/client/components/UserPage/Drawer/ControlPanel/widgets/DrawerTextField.tsx +++ b/src/client/components/UserPage/Drawer/ControlPanel/widgets/DrawerTextField.tsx @@ -48,6 +48,7 @@ const PrefixAdornment = ({ children }: PrefixAdornmentProps) => { type TextFieldStylesProps = { textFieldHeight: number + multiline?: boolean } const useTextFieldStyles = makeStyles((theme) => @@ -57,7 +58,8 @@ const useTextFieldStyles = makeStyles((theme) => marginTop: theme.spacing(1), marginBottom: 0, [theme.breakpoints.up('md')]: { - marginBottom: -19, + // marginBottom: (props: TextFieldStylesProps) => + // props.multiline ? 0 : -19, }, }, removePrefixPadding: { padding: theme.spacing(0) }, @@ -69,10 +71,18 @@ const useTextFieldStyles = makeStyles((theme) => lineHeight: 1.5, marginLeft: 14, marginRight: 0, + marginTop: (props: TextFieldStylesProps) => (props.multiline ? 8 : 0), + marginBottom: (props: TextFieldStylesProps) => (props.multiline ? 8 : 0), [theme.breakpoints.up('md')]: { marginRight: 14, }, }, + helperText: { + position: 'absolute', + top: '100%', + left: 0, + width: 'auto', + }, }), ) @@ -83,10 +93,17 @@ type TextFieldProps = { prefix?: string error?: boolean helperText?: string + multiline?: boolean + rows?: number + rowsMax?: number + FormHelperTextProps?: object } export default function DrawerTextField(props: TextFieldProps) { - const classes = useTextFieldStyles({ textFieldHeight: TEXT_FIELD_HEIGHT }) + const classes = useTextFieldStyles({ + textFieldHeight: TEXT_FIELD_HEIGHT, + multiline: props.multiline, + }) return ( ) } diff --git a/src/client/components/UserPage/index.jsx b/src/client/components/UserPage/index.jsx index 74cb948cf..eeb1a2364 100644 --- a/src/client/components/UserPage/index.jsx +++ b/src/client/components/UserPage/index.jsx @@ -9,6 +9,7 @@ import BaseLayout from '../BaseLayout' import UserLinkTable from './UserLinkTable' import EmptyState from './EmptyState' import useIsFiltered from './EmptyState/isFiltered' +import loginActions from '~/actions/login' /** * List URLs belonging to the user in a table. @@ -17,12 +18,15 @@ import useIsFiltered from './EmptyState/isFiltered' const mapStateToProps = (state) => ({ isLoggedIn: state.login.isLoggedIn, createUrlModal: state.user.createUrlModal, + emailValidator: state.login.emailValidator, }) const mapDispatchToProps = (dispatch) => ({ onCreateUrl: (history) => dispatch(userActions.createUrlOrRedirect(history)), closeCreateUrlModal: () => dispatch(userActions.closeCreateUrlModal()), getUrlsForUser: () => dispatch(userActions.getUrlsForUser()), + getEmailValidator: () => + dispatch(loginActions.getEmailValidationGlobExpression()), }) /** @@ -35,13 +39,20 @@ const UserPage = ({ closeCreateUrlModal, history, getUrlsForUser, + getEmailValidator, + emailValidator, }) => { const fetchingUrls = useSelector((state) => state.user.isFetchingUrls) const urlCount = useSelector((state) => state.user.urlCount) const urlsFiltered = useIsFiltered() useEffect(() => { - if (isLoggedIn) getUrlsForUser() + if (isLoggedIn) { + getUrlsForUser() + if (!emailValidator) { + getEmailValidator() + } + } }, []) if (isLoggedIn) { diff --git a/src/client/reducers/user/index.ts b/src/client/reducers/user/index.ts index 399bbcb8b..9b2fa6f19 100644 --- a/src/client/reducers/user/index.ts +++ b/src/client/reducers/user/index.ts @@ -5,6 +5,8 @@ import { OPEN_CREATE_URL_MODAL, RESET_USER_STATE, SET_CREATE_SHORT_LINK_ERROR, + SET_EDITED_CONTACT_EMAIL, + SET_EDITED_DESCRIPTION, SET_EDITED_LONG_URL, SET_IS_UPLOADING, SET_LAST_CREATED_LINK, @@ -103,6 +105,36 @@ const user: (state: UserState, action: UserActionType) => UserState = ( } break } + case SET_EDITED_CONTACT_EMAIL: { + const { editedContactEmail, shortUrl } = action.payload + nextState = { + urls: state.urls.map((url) => { + if (shortUrl !== url.shortUrl) { + return url + } + return { + ...url, + editedContactEmail, + } + }), + } + break + } + case SET_EDITED_DESCRIPTION: { + const { editedDescription, shortUrl } = action.payload + nextState = { + urls: state.urls.map((url) => { + if (shortUrl !== url.shortUrl) { + return url + } + return { + ...url, + editedDescription, + } + }), + } + break + } case SET_RANDOM_SHORT_URL: nextState = { shortUrl: action.payload, diff --git a/src/client/reducers/user/types.ts b/src/client/reducers/user/types.ts index 79df640fb..311e44046 100644 --- a/src/client/reducers/user/types.ts +++ b/src/client/reducers/user/types.ts @@ -8,6 +8,10 @@ export type UrlType = { state: UrlState updatedAt: string userId: number + description: string + editedDescription: string + contactEmail: string + editedContactEmail: string } export type UserState = { diff --git a/src/client/theme/index.ts b/src/client/theme/index.ts index dea154f36..ea140bd15 100644 --- a/src/client/theme/index.ts +++ b/src/client/theme/index.ts @@ -114,6 +114,11 @@ export default responsiveFontSizes( backgroundColor: '#f9f9f9', }, }, + MuiInputBase: { + input: { + fontSize: '14px', + }, + }, MuiCssBaseline: { '@global': { // Used for crest symbol in government masthead. diff --git a/src/server/api/login/validators.ts b/src/server/api/login/validators.ts index d0177c3a7..c9eed68ce 100644 --- a/src/server/api/login/validators.ts +++ b/src/server/api/login/validators.ts @@ -1,14 +1,5 @@ import * as Joi from '@hapi/joi' -import validator from 'validator' -import { emailValidator } from '../../config' - -/** - * Checks if an email is valid and whether it follows a specified regex pattern. - * @param email The email to be validated. - */ -function isValidGovEmail(email: string) { - return validator.isEmail(email) && emailValidator.match(email) -} +import { isValidGovEmail } from '../../util/email' export const otpVerificationSchema = Joi.object({ email: Joi.string() diff --git a/src/server/api/user/validators.ts b/src/server/api/user/validators.ts index e44ff5d19..a59a528f0 100644 --- a/src/server/api/user/validators.ts +++ b/src/server/api/user/validators.ts @@ -2,6 +2,8 @@ import * as Joi from '@hapi/joi' import { ACTIVE, INACTIVE } from '../../models/types' import blacklist from '../../resources/blacklist' import { isHttps, isValidShortUrl } from '../../../shared/util/validation' +import { LINK_DESCRIPTION_MAX_LENGTH } from '../../../shared/constants' +import { isValidGovEmail } from '../../util/email' export const urlRetrievalSchema = Joi.object({ userId: Joi.number().required(), @@ -51,6 +53,15 @@ export const urlEditSchema = Joi.object({ file: Joi.object().keys().required(), }), state: Joi.string().allow(ACTIVE, INACTIVE).only(), + description: Joi.string().allow('').max(LINK_DESCRIPTION_MAX_LENGTH), + contactEmail: Joi.string() + .allow(null) + .custom((email: string, helpers) => { + if (!isValidGovEmail(email)) { + return helpers.message({ custom: 'Not a valid gov email or null' }) + } + return email + }), }).oxor('longUrl', 'files') export const ownershipTransferSchema = Joi.object({ diff --git a/src/server/controllers/UserController.ts b/src/server/controllers/UserController.ts index eea79210c..743cbe2a7 100644 --- a/src/server/controllers/UserController.ts +++ b/src/server/controllers/UserController.ts @@ -60,7 +60,14 @@ export class UserController implements UserControllerInterface { req: Express.Request, res: Express.Response, ) => Promise = async (req, res) => { - const { userId, longUrl, shortUrl, state }: UrlEditRequest = req.body + const { + userId, + longUrl, + shortUrl, + state, + description, + contactEmail, + }: UrlEditRequest = req.body const file = req.files?.file if (Array.isArray(file)) { res.badRequest(jsonMessage('Only single file uploads are supported.')) @@ -73,11 +80,20 @@ export class UserController implements UserControllerInterface { state === 'ACTIVE' ? StorableUrlState.Active : StorableUrlState.Inactive } + let newContactEmail: string | undefined | null + if (contactEmail) { + newContactEmail = contactEmail?.trim().toLowerCase() + } else if (contactEmail === null) { + newContactEmail = null + } + try { const url = this.urlManagementService.updateUrl(userId, shortUrl, { longUrl, state: urlState, file, + contactEmail: newContactEmail, + description: description?.trim(), }) res.ok(url) } catch (error) { diff --git a/src/server/db_migrations/20200618_add_description_and_contact_column.sql b/src/server/db_migrations/20200618_add_description_and_contact_column.sql new file mode 100644 index 000000000..9374d3b5e --- /dev/null +++ b/src/server/db_migrations/20200618_add_description_and_contact_column.sql @@ -0,0 +1,16 @@ +-- This script should be the first to be run before search data +-- collection is deployed. This adds columns to both urls and url_histories +-- tables, which are backwards-compatible with the current +-- codebase as they default to empty strings. + +BEGIN TRANSACTION; + +ALTER TABLE urls ADD "description" text NOT NULL DEFAULT ''; + +ALTER TABLE url_histories ADD "description" text NOT NULL DEFAULT ''; + +ALTER TABLE urls ADD "contactEmail" text; + +ALTER TABLE url_histories ADD "contactEmail" text; + +COMMIT; \ No newline at end of file diff --git a/src/server/mappers/UrlMapper.ts b/src/server/mappers/UrlMapper.ts index 2ad13e38c..2701e287f 100644 --- a/src/server/mappers/UrlMapper.ts +++ b/src/server/mappers/UrlMapper.ts @@ -19,6 +19,8 @@ export class UrlMapper implements Mapper { isFile: urlType.isFile, createdAt: urlType.createdAt, updatedAt: urlType.updatedAt, + description: urlType.description, + contactEmail: urlType.contactEmail, } } } diff --git a/src/server/models/url.ts b/src/server/models/url.ts index 033693152..622ac3cc4 100644 --- a/src/server/models/url.ts +++ b/src/server/models/url.ts @@ -9,7 +9,7 @@ import { } from '../../shared/util/validation' import { sequelize } from '../util/sequelize' import { IdType } from '../../types/server/models' -import { DEV_ENV, ogHostname } from '../config' +import { DEV_ENV, emailValidator, ogHostname } from '../config' import { StorableUrlState } from '../repositories/enums' interface UrlBaseType extends IdType { @@ -17,6 +17,8 @@ interface UrlBaseType extends IdType { readonly longUrl: string readonly state: StorableUrlState readonly isFile: boolean + readonly contactEmail: string | null + readonly description: string } export interface UrlType extends IdType, UrlBaseType, Sequelize.Model { @@ -86,6 +88,20 @@ export const Url = sequelize.define( type: Sequelize.BOOLEAN, allowNull: false, }, + contactEmail: { + type: Sequelize.TEXT, + allowNull: true, + validate: { + isEmail: true, + isLowercase: true, + is: emailValidator.makeRe(), + }, + }, + description: { + type: Sequelize.TEXT, + allowNull: false, + defaultValue: '', + }, }, { hooks: { @@ -165,6 +181,14 @@ export const UrlHistory = sequelize.define('url_history', { type: Sequelize.BOOLEAN, allowNull: false, }, + contactEmail: { + type: Sequelize.TEXT, + allowNull: true, + }, + description: { + type: Sequelize.TEXT, + allowNull: false, + }, }) /** @@ -183,6 +207,8 @@ const writeToUrlHistory = async ( urlShortUrl: urlObj.shortUrl, longUrl: urlObj.longUrl, isFile: urlObj.isFile, + contactEmail: urlObj.contactEmail, + description: urlObj.description, }, { transaction: options.transaction, diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index 9f3e6b2fd..509368eb5 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -12,6 +12,8 @@ export type StorableUrl = Pick< | 'isFile' | 'createdAt' | 'updatedAt' + | 'description' + | 'contactEmail' > /** diff --git a/src/server/services/UrlManagementService.ts b/src/server/services/UrlManagementService.ts index a1129c03e..870b82331 100644 --- a/src/server/services/UrlManagementService.ts +++ b/src/server/services/UrlManagementService.ts @@ -76,7 +76,7 @@ export class UrlManagementService implements UrlManagementServiceInterface { shortUrl: string, options: UpdateUrlOptions, ) => Promise = async (userId, shortUrl, options) => { - const { state, longUrl, file } = options + const { state, longUrl, file, description, contactEmail } = options const url = await this.userRepository.findOneUrlForUser(userId, shortUrl) @@ -92,7 +92,11 @@ export class UrlManagementService implements UrlManagementServiceInterface { } : undefined - return this.urlRepository.update(url, { longUrl, state }, storableFile) + return this.urlRepository.update( + url, + { longUrl, state, description, contactEmail }, + storableFile, + ) } changeOwnership: ( diff --git a/src/server/services/types.ts b/src/server/services/types.ts index 30f4b3822..95e95caa0 100644 --- a/src/server/services/types.ts +++ b/src/server/services/types.ts @@ -13,5 +13,7 @@ export enum RedirectType { } export type UpdateUrlOptions = Partial< - Pick & { file: GoUploadedFile } + Pick & { + file: GoUploadedFile + } > diff --git a/src/server/util/email.ts b/src/server/util/email.ts new file mode 100644 index 000000000..fc384a8dd --- /dev/null +++ b/src/server/util/email.ts @@ -0,0 +1,12 @@ +import validator from 'validator' +import { emailValidator } from '../config' + +/** + * Checks if an email is valid and whether it follows a specified regex pattern. + * @param email The email to be validated. + */ +export function isValidGovEmail(email: string) { + return validator.isEmail(email) && emailValidator.match(email) +} + +export default { isValidGovEmail } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 3b00ff3e0..4a824c2b3 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,3 +1,2 @@ export const MAX_FILE_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB - -export default { MAX_FILE_UPLOAD_SIZE } +export const LINK_DESCRIPTION_MAX_LENGTH = 200 diff --git a/src/types/server/controllers/UserController.d.ts b/src/types/server/controllers/UserController.d.ts index 19bd445a3..16894a02e 100644 --- a/src/types/server/controllers/UserController.d.ts +++ b/src/types/server/controllers/UserController.d.ts @@ -10,6 +10,11 @@ type ShortUrlProperty = { shortUrl: string } +type LinkInformationProperties = { + contactEmail: string + description: string +} + type ShortUrlOperationProperty = UserIdProperty & ShortUrlProperty type NewUserEmailProperty = { @@ -31,4 +36,5 @@ export type OwnershipTransferRequest = ShortUrlOperationProperty & export type UrlEditRequest = ShortUrlOperationProperty & OptionalStateProperty & - OptionalLongUrlProperty + OptionalLongUrlProperty & + Partial