diff --git a/lib/types/account-types.js b/lib/types/account-types.js index 3894b4ac8..0423f8fd5 100644 --- a/lib/types/account-types.js +++ b/lib/types/account-types.js @@ -1,150 +1,160 @@ // @flow import type { PlatformDetails, DeviceType } from './device-types'; import type { CalendarQuery, CalendarResult, RawEntryInfo, } from './entry-types'; import type { RawMessageInfo, MessageTruncationStatuses, GenericMessagesResult, } from './message-types'; import type { PreRequestUserState } from './session-types'; import type { RawThreadInfo } from './thread-types'; import type { UserInfo, LoggedOutUserInfo, LoggedInUserInfo, OldLoggedInUserInfo, } from './user-types'; export type ResetPasswordRequest = { +usernameOrEmail: string, }; export type LogOutResult = { +currentUserInfo: ?LoggedOutUserInfo, +preRequestUserState: PreRequestUserState, }; export type LogOutResponse = { +currentUserInfo: LoggedOutUserInfo, }; export type RegisterInfo = { ...LogInExtraInfo, +username: string, +password: string, }; type DeviceTokenUpdateRequest = { +deviceToken: string, }; export type RegisterRequest = { +username: string, +password: string, +calendarQuery?: ?CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, }; export type RegisterResponse = { id: string, rawMessageInfos: $ReadOnlyArray, currentUserInfo: OldLoggedInUserInfo | LoggedInUserInfo, cookieChange: { threadInfos: { +[id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, }, }; export type RegisterResult = { +currentUserInfo: LoggedInUserInfo, +rawMessageInfos: $ReadOnlyArray, +threadInfos: { +[id: string]: RawThreadInfo }, +userInfos: $ReadOnlyArray, +calendarQuery: CalendarQuery, }; export type DeleteAccountRequest = { +password: string, }; export type LogInActionSource = | 'COOKIE_INVALIDATION_RESOLUTION_ATTEMPT' | 'APP_START_NATIVE_CREDENTIALS_AUTO_LOG_IN' | 'APP_START_REDUX_LOGGED_IN_BUT_INVALID_COOKIE' | 'SOCKET_AUTH_ERROR_RESOLUTION_ATTEMPT' | 'SQLITE_OP_FAILURE' | 'SQLITE_LOAD_FAILURE'; export type LogInStartingPayload = { +calendarQuery: CalendarQuery, +source?: LogInActionSource, }; export type LogInExtraInfo = { +calendarQuery: CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, }; export type LogInInfo = { ...LogInExtraInfo, +username: string, +password: string, +source?: ?LogInActionSource, }; export type LogInRequest = { +usernameOrEmail?: ?string, +username?: ?string, +password: string, +calendarQuery?: ?CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +watchedIDs: $ReadOnlyArray, }; export type LogInResponse = { currentUserInfo: LoggedInUserInfo | OldLoggedInUserInfo, rawMessageInfos: $ReadOnlyArray, truncationStatuses: MessageTruncationStatuses, userInfos: $ReadOnlyArray, rawEntryInfos?: ?$ReadOnlyArray, serverTime: number, cookieChange: { threadInfos: { +[id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, }, }; export type LogInResult = { +threadInfos: { +[id: string]: RawThreadInfo }, +currentUserInfo: LoggedInUserInfo, +messagesResult: GenericMessagesResult, +userInfos: $ReadOnlyArray, +calendarResult: CalendarResult, +updatesCurrentAsOf: number, +source?: ?LogInActionSource, }; export type UpdatePasswordRequest = { code: string, password: string, calendarQuery?: ?CalendarQuery, deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, platformDetails: PlatformDetails, watchedIDs: $ReadOnlyArray, }; export type AccessRequest = { +email: string, +platform: DeviceType, }; export type EmailSubscriptionRequest = { +email: string, }; +export type UpdateUserSettingsRequest = { + +name: 'default_user_notifications', + +data: NotificationTypes, +}; + +export const userSettingsTypes = Object.freeze({ + DEFAULT_NOTIFICATIONS: 'default_user_notifications', +}); + +export type NotificationTypes = 'all' | 'background' | 'none'; diff --git a/lib/types/endpoints.js b/lib/types/endpoints.js index 4fda9b57e..52e0b9220 100644 --- a/lib/types/endpoints.js +++ b/lib/types/endpoints.js @@ -1,109 +1,109 @@ // @flow export type APIRequest = { endpoint: Endpoint, input: Object, }; export type SocketAPIHandler = (request: APIRequest) => Promise; export type Endpoint = | HTTPOnlyEndpoint | SocketOnlyEndpoint | HTTPPreferredEndpoint | SocketPreferredEndpoint; // Endpoints that can cause session changes should occur over HTTP, since the // socket code does not currently support changing sessions. In the future they // could be made to work for native, but cookie changes on web require HTTP // since websockets aren't able to Set-Cookie. Note that technically any // endpoint can cause a sessionChange, and in that case the server will close // the socket with a specific error code, and the client will proceed via HTTP. const sessionChangingEndpoints = Object.freeze({ LOG_OUT: 'log_out', DELETE_ACCOUNT: 'delete_account', CREATE_ACCOUNT: 'create_account', LOG_IN: 'log_in', UPDATE_PASSWORD: 'update_password', }); type SessionChangingEndpoint = $Values; // We do uploads over HTTP as well. This is because Websockets use TCP, which // guarantees ordering. That means that if we start an upload, any messages we // try to send the server after the upload starts will have to wait until the // upload ends. To avoid blocking other messages we upload using HTTP // multipart/form-data. const uploadEndpoints = Object.freeze({ UPLOAD_MULTIMEDIA: 'upload_multimedia', }); type UploadEndpoint = $Values; type HTTPOnlyEndpoint = SessionChangingEndpoint | UploadEndpoint; const socketOnlyEndpoints = Object.freeze({ UPDATE_ACTIVITY: 'update_activity', UPDATE_CALENDAR_QUERY: 'update_calendar_query', }); type SocketOnlyEndpoint = $Values; const socketPreferredEndpoints = Object.freeze({ CREATE_ENTRY: 'create_entry', CREATE_ERROR_REPORT: 'create_error_report', CREATE_MULTIMEDIA_MESSAGE: 'create_multimedia_message', CREATE_TEXT_MESSAGE: 'create_text_message', CREATE_THREAD: 'create_thread', DELETE_ENTRY: 'delete_entry', DELETE_THREAD: 'delete_thread', DELETE_UPLOAD: 'delete_upload', FETCH_ENTRIES: 'fetch_entries', FETCH_ENTRY_REVISIONS: 'fetch_entry_revisions', FETCH_ERROR_REPORT_INFOS: 'fetch_error_report_infos', FETCH_MESSAGES: 'fetch_messages', GET_USER_PUBLIC_KEYS: 'get_user_public_keys', JOIN_THREAD: 'join_thread', LEAVE_THREAD: 'leave_thread', REMOVE_MEMBERS: 'remove_members', REQUEST_ACCESS: 'request_access', RESTORE_ENTRY: 'restore_entry', SEARCH_USERS: 'search_users', SEND_PASSWORD_RESET_EMAIL: 'send_password_reset_email', SEND_VERIFICATION_EMAIL: 'send_verification_email', SET_THREAD_UNREAD_STATUS: 'set_thread_unread_status', UPDATE_ACCOUNT: 'update_account', - UPDATE_DEFAULT_USER_SETTINGS: 'update_default_user_settings', + UPDATE_USER_SETTINGS: 'update_user_settings', UPDATE_DEVICE_TOKEN: 'update_device_token', UPDATE_ENTRY: 'update_entry', UPDATE_RELATIONSHIPS: 'update_relationships', UPDATE_ROLE: 'update_role', UPDATE_THREAD: 'update_thread', UPDATE_USER_SUBSCRIPTION: 'update_user_subscription', VERIFY_CODE: 'verify_code', }); type SocketPreferredEndpoint = $Values; const httpPreferredEndpoints = Object.freeze({ CREATE_REPORT: 'create_report', CREATE_REPORTS: 'create_reports', }); type HTTPPreferredEndpoint = $Values; const socketPreferredEndpointSet = new Set([ ...Object.values(socketOnlyEndpoints), ...Object.values(socketPreferredEndpoints), ]); export function endpointIsSocketPreferred(endpoint: Endpoint): boolean { return socketPreferredEndpointSet.has(endpoint); } const socketSafeEndpointSet = new Set([ ...Object.values(socketOnlyEndpoints), ...Object.values(socketPreferredEndpoints), ...Object.values(httpPreferredEndpoints), ]); export function endpointIsSocketSafe(endpoint: Endpoint): boolean { return socketSafeEndpointSet.has(endpoint); } const socketOnlyEndpointSet = new Set(Object.values(socketOnlyEndpoints)); export function endpointIsSocketOnly(endpoint: Endpoint): boolean { return socketOnlyEndpointSet.has(endpoint); } diff --git a/server/src/endpoints.js b/server/src/endpoints.js index 0061a22d7..d65765484 100644 --- a/server/src/endpoints.js +++ b/server/src/endpoints.js @@ -1,99 +1,101 @@ // @flow import type { Endpoint } from 'lib/types/endpoints'; import { updateActivityResponder, threadSetUnreadStatusResponder, } from './responders/activity-responders'; import { deviceTokenUpdateResponder } from './responders/device-responders'; import { entryFetchResponder, entryRevisionFetchResponder, entryCreationResponder, entryUpdateResponder, entryDeletionResponder, entryRestorationResponder, calendarQueryUpdateResponder, } from './responders/entry-responders'; import type { JSONResponder } from './responders/handlers'; import { getUserPublicKeysResponder } from './responders/keys-responders'; import { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, } from './responders/message-responders'; import { updateRelationshipsResponder } from './responders/relationship-responders'; import { reportCreationResponder, reportMultiCreationResponder, errorReportFetchInfosResponder, } from './responders/report-responders'; import { userSearchResponder } from './responders/search-responders'; import { threadDeletionResponder, roleUpdateResponder, memberRemovalResponder, threadLeaveResponder, threadUpdateResponder, threadCreationResponder, threadJoinResponder, } from './responders/thread-responders'; import { userSubscriptionUpdateResponder, accountUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, passwordUpdateResponder, requestAccessResponder, + updateUserSettingsResponder, } from './responders/user-responders'; import { codeVerificationResponder } from './responders/verification-responders'; import { uploadDeletionResponder } from './uploads/uploads'; const jsonEndpoints: { [id: Endpoint]: JSONResponder } = { create_account: accountCreationResponder, create_entry: entryCreationResponder, create_error_report: reportCreationResponder, create_multimedia_message: multimediaMessageCreationResponder, create_report: reportCreationResponder, create_reports: reportMultiCreationResponder, create_text_message: textMessageCreationResponder, create_thread: threadCreationResponder, delete_account: accountDeletionResponder, delete_entry: entryDeletionResponder, delete_thread: threadDeletionResponder, delete_upload: uploadDeletionResponder, fetch_entries: entryFetchResponder, fetch_entry_revisions: entryRevisionFetchResponder, fetch_error_report_infos: errorReportFetchInfosResponder, fetch_messages: messageFetchResponder, get_user_public_keys: getUserPublicKeysResponder, join_thread: threadJoinResponder, leave_thread: threadLeaveResponder, log_in: logInResponder, log_out: logOutResponder, remove_members: memberRemovalResponder, request_access: requestAccessResponder, restore_entry: entryRestorationResponder, search_users: userSearchResponder, send_password_reset_email: sendPasswordResetEmailResponder, send_verification_email: sendVerificationEmailResponder, set_thread_unread_status: threadSetUnreadStatusResponder, update_account: accountUpdateResponder, update_activity: updateActivityResponder, update_calendar_query: calendarQueryUpdateResponder, + update_user_settings: updateUserSettingsResponder, update_device_token: deviceTokenUpdateResponder, update_entry: entryUpdateResponder, update_password: passwordUpdateResponder, update_relationships: updateRelationshipsResponder, update_role: roleUpdateResponder, update_thread: threadUpdateResponder, update_user_subscription: userSubscriptionUpdateResponder, verify_code: codeVerificationResponder, }; export { jsonEndpoints }; diff --git a/server/src/responders/user-responders.js b/server/src/responders/user-responders.js index 895a1ac86..b7d462f4a 100644 --- a/server/src/responders/user-responders.js +++ b/server/src/responders/user-responders.js @@ -1,330 +1,351 @@ // @flow import invariant from 'invariant'; import t from 'tcomb'; import bcrypt from 'twin-bcrypt'; import type { ResetPasswordRequest, LogOutResponse, DeleteAccountRequest, RegisterResponse, RegisterRequest, LogInResponse, LogInRequest, UpdatePasswordRequest, AccessRequest, + UpdateUserSettingsRequest, } from 'lib/types/account-types'; +import { userSettingsTypes } from 'lib/types/account-types'; import { defaultNumberPerThread } from 'lib/types/message-types'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResponse, } from 'lib/types/subscription-types'; import type { AccountUpdate } from 'lib/types/user-types'; import { ServerError } from 'lib/utils/errors'; import { values } from 'lib/utils/objects'; import { promiseAll } from 'lib/utils/promises'; import createAccount from '../creators/account-creator'; import { dbQuery, SQL } from '../database/database'; import { deleteAccount } from '../deleters/account-deleters'; import { deleteCookie } from '../deleters/cookie-deleters'; import { sendAccessRequestEmailToAshoat } from '../emails/access-request'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchKnownUserInfos, fetchLoggedInUserInfo, } from '../fetchers/user-fetchers'; import { createNewAnonymousCookie, createNewUserCookie, setNewSession, } from '../session/cookies'; import type { Viewer } from '../session/viewer'; import { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updatePassword, + updateUserSettings, } from '../updaters/account-updaters'; import { userSubscriptionUpdater } from '../updaters/user-subscription-updaters'; import { validateInput, tShape, tPlatformDetails, tDeviceType, tPassword, tEmail, tOldValidUsername, } from '../utils/validation-utils'; import { entryQueryInputValidator, newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, } from './entry-responders'; const subscriptionUpdateRequestInputValidator = tShape({ threadID: t.String, updatedFields: tShape({ pushNotifs: t.maybe(t.Boolean), home: t.maybe(t.Boolean), }), }); async function userSubscriptionUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: SubscriptionUpdateRequest = input; await validateInput(viewer, subscriptionUpdateRequestInputValidator, request); const threadSubscription = await userSubscriptionUpdater(viewer, request); return { threadSubscription }; } const accountUpdateInputValidator = tShape({ updatedFields: tShape({ email: t.maybe(tEmail), password: t.maybe(tPassword), }), currentPassword: tPassword, }); async function accountUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: AccountUpdate = input; await validateInput(viewer, accountUpdateInputValidator, request); await accountUpdater(viewer, request); } async function sendVerificationEmailResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); await checkAndSendVerificationEmail(viewer); } const resetPasswordRequestInputValidator = tShape({ usernameOrEmail: t.union([tEmail, tOldValidUsername]), }); async function sendPasswordResetEmailResponder( viewer: Viewer, input: any, ): Promise { const request: ResetPasswordRequest = input; await validateInput(viewer, resetPasswordRequestInputValidator, request); await checkAndSendPasswordResetEmail(request); } async function logOutResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); if (viewer.loggedIn) { const [anonymousViewerData] = await Promise.all([ createNewAnonymousCookie({ platformDetails: viewer.platformDetails, deviceToken: viewer.deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(anonymousViewerData); } return { currentUserInfo: { id: viewer.id, anonymous: true, }, }; } const deleteAccountRequestInputValidator = tShape({ password: tPassword, }); async function accountDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: DeleteAccountRequest = input; await validateInput(viewer, deleteAccountRequestInputValidator, request); const result = await deleteAccount(viewer, request); invariant(result, 'deleteAccount should return result if handed request'); return result; } const deviceTokenUpdateRequestInputValidator = tShape({ deviceType: t.maybe(t.enums.of(['ios', 'android'])), deviceToken: t.String, }); const registerRequestInputValidator = tShape({ username: t.String, email: t.maybe(tEmail), password: tPassword, calendarQuery: t.maybe(newEntryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function accountCreationResponder( viewer: Viewer, input: any, ): Promise { const request: RegisterRequest = input; await validateInput(viewer, registerRequestInputValidator, request); return await createAccount(viewer, request); } const logInRequestInputValidator = tShape({ username: t.maybe(t.String), usernameOrEmail: t.maybe(t.union([tEmail, tOldValidUsername])), password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function logInResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, logInRequestInputValidator, input); const request: LogInRequest = input; const calendarQuery = request.calendarQuery ? normalizeCalendarQuery(request.calendarQuery) : null; const promises = {}; if (calendarQuery) { promises.verifyCalendarQueryThreadIDs = verifyCalendarQueryThreadIDs( calendarQuery, ); } const username = request.username ?? request.usernameOrEmail; if (!username) { throw new ServerError('invalid_parameters'); } const userQuery = SQL` SELECT id, hash, username FROM users WHERE LCASE(username) = LCASE(${username}) `; promises.userQuery = dbQuery(userQuery); const { userQuery: [userResult], } = await promiseAll(promises); if (userResult.length === 0) { throw new ServerError('invalid_parameters'); } const userRow = userResult[0]; if (!userRow.hash || !bcrypt.compareSync(request.password, userRow.hash)) { throw new ServerError('invalid_credentials'); } const id = userRow.id.toString(); const newServerTime = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [userViewerData] = await Promise.all([ createNewUserCookie(id, { platformDetails: request.platformDetails, deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(userViewerData); if (calendarQuery) { await setNewSession(viewer, calendarQuery, newServerTime); } const threadCursors = {}; for (const watchedThreadID of request.watchedIDs) { threadCursors[watchedThreadID] = null; } const threadSelectionCriteria = { threadCursors, joinedThreads: true }; const [ threadsResult, messagesResult, entriesResult, userInfos, currentUserInfo, ] = await Promise.all([ fetchThreadInfos(viewer), fetchMessageInfos(viewer, threadSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, fetchKnownUserInfos(viewer), fetchLoggedInUserInfo(viewer), ]); const rawEntryInfos = entriesResult ? entriesResult.rawEntryInfos : null; const response: LogInResponse = { currentUserInfo, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, serverTime: newServerTime, userInfos: values(userInfos), cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: [], }, }; if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } const updatePasswordRequestInputValidator = tShape({ code: t.String, password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function passwordUpdateResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, updatePasswordRequestInputValidator, input); const request: UpdatePasswordRequest = input; if (request.calendarQuery) { request.calendarQuery = normalizeCalendarQuery(request.calendarQuery); } return await updatePassword(viewer, request); } const accessRequestInputValidator = tShape({ email: tEmail, platform: tDeviceType, }); async function requestAccessResponder( viewer: Viewer, input: any, ): Promise { const request: AccessRequest = input; await validateInput(viewer, accessRequestInputValidator, request); await sendAccessRequestEmailToAshoat(request); } +const updateUserSettingsInputValidator = tShape({ + name: t.irreducible( + userSettingsTypes.DEFAULT_NOTIFICATIONS, + x => x === userSettingsTypes.DEFAULT_NOTIFICATIONS, + ), + data: t.enums.of(Object.freeze(['all', 'background', 'none'])), +}); + +async function updateUserSettingsResponder( + viewer: Viewer, + input: any, +): Promise { + const request: UpdateUserSettingsRequest = input; + await validateInput(viewer, updateUserSettingsInputValidator, request); + return await updateUserSettings(viewer, request); +} + export { userSubscriptionUpdateResponder, accountUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, passwordUpdateResponder, requestAccessResponder, + updateUserSettingsResponder, }; diff --git a/server/src/updaters/account-updaters.js b/server/src/updaters/account-updaters.js index 9fe857fae..0d10badea 100644 --- a/server/src/updaters/account-updaters.js +++ b/server/src/updaters/account-updaters.js @@ -1,92 +1,111 @@ // @flow import bcrypt from 'twin-bcrypt'; import type { ResetPasswordRequest, UpdatePasswordRequest, + UpdateUserSettingsRequest, } from 'lib/types/account-types'; import { updateTypes } from 'lib/types/update-types'; import type { AccountUpdate } from 'lib/types/user-types'; import { ServerError } from 'lib/utils/errors'; import { createUpdates } from '../creators/update-creator'; import { dbQuery, SQL } from '../database/database'; import type { Viewer } from '../session/viewer'; async function accountUpdater( viewer: Viewer, update: AccountUpdate, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const newPassword = update.updatedFields.password; if (!newPassword) { // If it's an old client it may have given us an email, // but we don't store those anymore return; } const verifyQuery = SQL` SELECT username, hash FROM users WHERE id = ${viewer.userID} `; const [verifyResult] = await dbQuery(verifyQuery); if (verifyResult.length === 0) { throw new ServerError('internal_error'); } const verifyRow = verifyResult[0]; if (!bcrypt.compareSync(update.currentPassword, verifyRow.hash)) { throw new ServerError('invalid_credentials'); } const changedFields = { hash: bcrypt.hashSync(newPassword) }; const saveQuery = SQL` UPDATE users SET ${changedFields} WHERE id = ${viewer.userID} `; await dbQuery(saveQuery); const updateDatas = [ { type: updateTypes.UPDATE_CURRENT_USER, userID: viewer.userID, time: Date.now(), }, ]; await createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'broadcast', }); } // eslint-disable-next-line no-unused-vars async function checkAndSendVerificationEmail(viewer: Viewer): Promise { // We don't want to crash old clients that call this, // but we have nothing we can do because we no longer store email addresses } async function checkAndSendPasswordResetEmail( // eslint-disable-next-line no-unused-vars request: ResetPasswordRequest, ): Promise { // We don't want to crash old clients that call this, // but we have nothing we can do because we no longer store email addresses } /* eslint-disable no-unused-vars */ async function updatePassword( viewer: Viewer, request: UpdatePasswordRequest, ): Promise { /* eslint-enable no-unused-vars */ // We have no way to handle this request anymore throw new ServerError('deprecated'); } +async function updateUserSettings( + viewer: Viewer, + request: UpdateUserSettingsRequest, +) { + if (!viewer.loggedIn) { + throw new ServerError('not_logged_in'); + } + + const createOrUpdateSettingsQuery = SQL` + INSERT INTO settings (user, name, data) + VALUES ${[[viewer.id, request.name, request.data]]} + ON DUPLICATE KEY UPDATE data = VALUES(data) + `; + + await dbQuery(createOrUpdateSettingsQuery); +} + export { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, + updateUserSettings, updatePassword, };