diff --git a/backend/package.json b/backend/package.json index 3b0b8ad..231379e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,6 +9,7 @@ }, "scripts": { "dev": "tsx watch src/index.ts", + "dev:migrate": "pnpm tsx src/umzug-runner.ts up", "lint": "pnpm oxlint && pnpm eslint && tsc --noEmit", "start": "node dist", "format": "prettier --check --ignore-path ../.gitignore .", diff --git a/backend/src/__generated__/resolvers-types.ts b/backend/src/__generated__/resolvers-types.ts index d29a43c..d19d2ba 100644 --- a/backend/src/__generated__/resolvers-types.ts +++ b/backend/src/__generated__/resolvers-types.ts @@ -500,6 +500,7 @@ export type UpdateIssueInput = { export type UpdateMeInput = { firstName?: InputMaybe; lastName?: InputMaybe; + settings?: InputMaybe; }; export type UploadAssetInput = { @@ -516,6 +517,7 @@ export type User = { id?: Maybe; lastName?: Maybe; name?: Maybe; + settings?: Maybe; }; export type ViewState = { @@ -1078,6 +1080,7 @@ export type UserResolvers, ParentType, ContextType>; lastName?: Resolver, ParentType, ContextType>; name?: Resolver, ParentType, ContextType>; + settings?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }>; diff --git a/backend/src/db/migrations/20240112143058-add-settings-to-user.ts b/backend/src/db/migrations/20240112143058-add-settings-to-user.ts new file mode 100644 index 0000000..3b18238 --- /dev/null +++ b/backend/src/db/migrations/20240112143058-add-settings-to-user.ts @@ -0,0 +1,16 @@ +'use strict'; + +const TABLE_NAME = 'users'; +const COLUMN_NAME = 'settings'; + +/** @type {import('sequelize-cli').Migration} */ +export default { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn(TABLE_NAME, COLUMN_NAME, { + type: Sequelize.JSONB, + }); + }, + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn(TABLE_NAME, COLUMN_NAME); + }, +}; diff --git a/backend/src/db/models/types.ts b/backend/src/db/models/types.ts index 205b96a..37e0dd7 100644 --- a/backend/src/db/models/types.ts +++ b/backend/src/db/models/types.ts @@ -33,6 +33,7 @@ export class User extends Model, InferCreationAttributes; declare email: CreationOptional; declare avatarAssetId: CreationOptional; + declare settings: CreationOptional; // JSONB declare static associate?: Associate; diff --git a/backend/src/db/models/user.ts b/backend/src/db/models/user.ts index bb2c22e..b77550b 100644 --- a/backend/src/db/models/user.ts +++ b/backend/src/db/models/user.ts @@ -34,6 +34,10 @@ export default (sequelize: Sequelize) => key: 'id', }, }, + settings: { + type: DataTypes.JSONB, + field: 'settings', + }, createdAt: { type: DataTypes.DATE, field: 'created_at', diff --git a/backend/src/resolvers/issue/index.ts b/backend/src/resolvers/issue/index.ts index f3fa19b..44670b2 100644 --- a/backend/src/resolvers/issue/index.ts +++ b/backend/src/resolvers/issue/index.ts @@ -13,6 +13,7 @@ import { } from '../../__generated__/resolvers-types.js'; import { Issue as IssueModel } from '../../db/models/types.js'; import { websocketBroadcast } from '../../services/ws-server.js'; +import { formatUserForGraphql } from '../user/helpers'; const Query: QueryResolvers = { issues: async (parent, { input: { projectId, id, search, searchOperator } }, { db, dataLoaderContext }) => { @@ -404,10 +405,7 @@ const Issue: IssueResolvers = { if (!reporterUser) return null; - return { - ...reporterUser.toJSON(), - id: `${reporterUser.id}`, - }; + return formatUserForGraphql(reporterUser); }, assignee: async (parent, args, { db, dataLoaderContext, EXPECTED_OPTIONS_KEY }) => { const databaseIssue = await db.Issue.findByPk(Number(parent.id), { @@ -420,10 +418,7 @@ const Issue: IssueResolvers = { [EXPECTED_OPTIONS_KEY]: dataLoaderContext, }); - return { - ...user.toJSON(), - id: `${user.id}`, - }; + return formatUserForGraphql(user); }, project: async (parent, __, { db, dataLoaderContext, EXPECTED_OPTIONS_KEY }) => { if (parent.project) return parent.project; diff --git a/backend/src/resolvers/project/index.ts b/backend/src/resolvers/project/index.ts index 565c86b..89ffd39 100644 --- a/backend/src/resolvers/project/index.ts +++ b/backend/src/resolvers/project/index.ts @@ -9,6 +9,7 @@ import { type Resolvers, type ViewState, } from '../../__generated__/resolvers-types.js'; +import { formatUserForGraphql } from '../user/helpers'; const Query: QueryResolvers = { projects: async (parent, args, { db, dataLoaderContext }) => { @@ -74,9 +75,7 @@ const Query: QueryResolvers = { id: `${databaseProject.id}`, visibility: databaseProject.visibility as ProjectVisibility, boards: undefined, - users: databaseProject.users - ? databaseProject.users.map((user) => ({ ...user.toJSON(), id: `${user.id}` })) - : undefined, + users: databaseProject.users ? databaseProject.users.map(formatUserForGraphql) : undefined, }; }, projectTags: async (parent, { input: { projectId, id, name } }, { db, dataLoaderContext }) => { diff --git a/backend/src/resolvers/user/helpers.ts b/backend/src/resolvers/user/helpers.ts new file mode 100644 index 0000000..4035d8f --- /dev/null +++ b/backend/src/resolvers/user/helpers.ts @@ -0,0 +1,9 @@ +import type { User } from '../../db/models/types.js'; + +export const formatUserForGraphql = (user: User) => { + return { + ...user.toJSON(), + id: `${user.id}`, + settings: user.settings ? JSON.stringify(user.settings) : null, + }; +}; diff --git a/backend/src/resolvers/user/index.ts b/backend/src/resolvers/user/index.ts index cfd1c63..50b78ca 100644 --- a/backend/src/resolvers/user/index.ts +++ b/backend/src/resolvers/user/index.ts @@ -1,14 +1,10 @@ import { isUndefined } from 'lodash-es'; import type { MutationResolvers, QueryResolvers, UserResolvers } from '../../__generated__/resolvers-types.js'; -import { Asset as AssetModel, User as UserModel } from '../../db/models/types.js'; +import { Asset as AssetModel } from '../../db/models/types.js'; import { BUCKET_NAME } from '../../services/config.js'; import { minioClient } from '../../services/minio-client.js'; - -const formatUserResponse = (user: UserModel) => ({ - ...user.toJSON(), - id: `${user.id}`, -}); +import { formatUserForGraphql } from './helpers.js'; const Query: QueryResolvers = { users: async (parent, args, { db, dataLoaderContext }) => { @@ -16,7 +12,7 @@ const Query: QueryResolvers = { dataLoaderContext.prime(dbUsers); - return dbUsers.map(formatUserResponse); + return dbUsers.map(formatUserForGraphql); }, user: (parent, __, { db }) => { // if (externalId) { @@ -33,24 +29,30 @@ const Query: QueryResolvers = { [EXPECTED_OPTIONS_KEY]: dataLoaderContext, }); - return formatUserResponse(dbUser); + return formatUserForGraphql(dbUser); }, }; const Mutation: MutationResolvers = { updateMe: async (parent, { input }, { db, user, dataLoaderContext }) => { - const { firstName, lastName } = input; + // We reload here in cases where the cached user is out of date from fastify auth cache + await user.reload(); + + const { firstName, lastName, settings } = input; + if (!user.id) throw new Error('User not found'); if (!isUndefined(firstName)) user.firstName = firstName; if (!isUndefined(lastName)) user.lastName = lastName; + if (!isUndefined(settings)) user.settings = JSON.parse(settings); await user.save(); - dataLoaderContext.prime(user); - - return formatUserResponse(user); + return formatUserForGraphql(user); }, assignAssetAsAvatar: async (parent, { input }, { db, user, dataLoaderContext, EXPECTED_OPTIONS_KEY }) => { + // We reload here in cases where the cached user is out of date from fastify auth cache + await user.reload(); + const { assetId } = input; let findOldAvatarAsset: AssetModel; diff --git a/backend/src/server/fastify-hooks.ts b/backend/src/server/fastify-hooks.ts index 47457e1..f976910 100644 --- a/backend/src/server/fastify-hooks.ts +++ b/backend/src/server/fastify-hooks.ts @@ -5,6 +5,7 @@ import { GraphQLError } from 'graphql'; import processRequest from 'graphql-upload/processRequest.mjs'; import db from '../db/index.js'; +import { User as UserModel } from '../db/models/types.js'; import { cache } from '../services/cache.js'; import { ALLOW_LOGIN_DOMAINS_LIST, @@ -31,10 +32,10 @@ const addUserToRequest = async (request: CustomFastifyRequest, reply: FastifyRep const cacheKey = hash(token); - const cachedUser = cache.get(cacheKey); + const cachedUser = cache.get(cacheKey); if (cachedUser) { - request.user = db.User.build(cachedUser); + request.user = cachedUser; return; } diff --git a/backend/src/type-defs.ts b/backend/src/type-defs.ts index 7f310d5..ef7ddbb 100644 --- a/backend/src/type-defs.ts +++ b/backend/src/type-defs.ts @@ -60,6 +60,7 @@ const typeDefs = gql` name: String email: String avatarUrl: String + settings: String } type ProjectTag { @@ -339,6 +340,7 @@ const typeDefs = gql` input UpdateMeInput { firstName: String lastName: String + settings: String } input CreateIssueLinkInput { diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 5536ef8..5a45866 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -1,13 +1,14 @@ 'use client'; -import React from 'react'; import { useMutation } from '@apollo/client'; +import React from 'react'; + +import { AvatarUploadComponent } from '@/components/Avatar'; +import { UserInfo } from '@/components/Profile/UserInfo'; import { ASSIGN_ASSET_AS_AVATAR_MUTATION, UPLOAD_ASSET_MUTATION, } from '@/gql/gql-queries-mutations'; -import { UserInfo } from '@/components/Profile/UserInfo'; -import { AvatarUploadComponent } from '@/components/Avatar'; import { base64toBlob } from '@/services/utils'; export default function Profile() { @@ -68,50 +69,29 @@ export default function Profile() {
-
+
Profile
-

+

This information will be displayed publicly so be careful what you share.

-
- -
-
- - user/ - - -
-
-
-
+

+ Avatar +

-

+

Personal Information

-

+

Use a permanent address where you can receive mail.

diff --git a/frontend/components/Avatar/UploadComponent.tsx b/frontend/components/Avatar/UploadComponent.tsx index a14a962..926e1da 100644 --- a/frontend/components/Avatar/UploadComponent.tsx +++ b/frontend/components/Avatar/UploadComponent.tsx @@ -1,8 +1,10 @@ -import React, { useState } from 'react'; import dynamic from 'next/dynamic'; -const Avatar = dynamic(() => import('react-avatar-edit'), { ssr: false }); +import React, { useState } from 'react'; + import { Button } from '@/components/Button'; +const Avatar = dynamic(() => import('react-avatar-edit'), { ssr: false }); + export const AvatarUploadComponent = ({ onAvatarSave, }: { @@ -24,7 +26,7 @@ export const AvatarUploadComponent = ({ } return ( -
+

{preview && ( diff --git a/frontend/components/Header/DropdownUser.tsx b/frontend/components/Header/DropdownUser.tsx index f514ae5..d9bdc30 100644 --- a/frontend/components/Header/DropdownUser.tsx +++ b/frontend/components/Header/DropdownUser.tsx @@ -1,11 +1,13 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; -import Link from 'next/link'; -import { signOut } from 'next-auth/react'; + import { useQuery } from '@apollo/client'; +import type { Route } from 'next'; +import { signOut } from 'next-auth/react'; +import Link from 'next/link'; +import { useEffect, useRef, useState } from 'react'; + import { GET_ME } from '@/gql/gql-queries-mutations'; import { formatUserAvatarUrl, formatUserFullName } from '@/services/utils'; -import type { Route } from 'next'; const DropdownUser = () => { const [dropdownOpen, setDropdownOpen] = useState(false); @@ -152,31 +154,31 @@ const DropdownUser = () => { My Contacts -
  • - - - - - - Account Settings - -
  • + {/*
  • */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* Account Settings*/} + {/* */} + {/*
  • */}
    +
    ); } diff --git a/frontend/components/Profile/UserInfo.tsx b/frontend/components/Profile/UserInfo.tsx index 0832017..763663b 100644 --- a/frontend/components/Profile/UserInfo.tsx +++ b/frontend/components/Profile/UserInfo.tsx @@ -1,26 +1,65 @@ -import React from 'react'; import { useLazyQuery, useMutation } from '@apollo/client'; -import { GET_ME, UPDATE_ME } from '@/gql/gql-queries-mutations'; -import { SubmitHandler, useForm } from 'react-hook-form'; -import { Button } from '@/components/Button'; +import { Switch } from '@headlessui/react'; import { isEqual, pick } from 'lodash'; +import React from 'react'; +import { Controller, SubmitHandler, useForm } from 'react-hook-form'; + +import { Button } from '@/components/Button'; +import { GET_ME, UPDATE_ME } from '@/gql/gql-queries-mutations'; +import { classNames } from '@/services/utils'; type Inputs = { firstName: string; lastName: string; email: string; + settings: { + celebrateCompletedIssue: boolean; + }; }; -const formFieldsWeCanUpdate = ['firstName', 'lastName']; +const ToggleCelebrateCompletedIssue = ({ + checked, + onChange, +}: { + checked: boolean; + onChange: (...event: any[]) => void; +}) => { + return ( + + + + + Enable Celebrate Completed Issue + + + ); +}; + +const formFieldsWeCanUpdate = ['firstName', 'lastName', 'settings']; const inputClasses = - 'block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 disabled:border-slate-200 disabled:bg-slate-50 disabled:text-slate-500 disabled:shadow-none'; + 'block bg-surface text-primary w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-primary/40 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 disabled:border-slate-200 disabled:bg-surface-overlay disabled:text-slate-500 disabled:shadow-none disabled:cursor-not-allowed'; export const UserInfo = () => { const [loadDbUser] = useLazyQuery(GET_ME); const [updateMe] = useMutation(UPDATE_ME); const { + control, register, handleSubmit, formState: { defaultValues }, @@ -28,10 +67,15 @@ export const UserInfo = () => { defaultValues: async () => { const dbUser = await loadDbUser(); + const settings = JSON.parse(dbUser.data.me.settings); + return { firstName: dbUser.data.me.firstName, lastName: dbUser.data.me.lastName, email: dbUser.data.me.email, + settings: { + celebrateCompletedIssue: settings.celebrateCompletedIssue ?? true, + }, }; }, }); @@ -43,6 +87,9 @@ export const UserInfo = () => { return; } + // @ts-ignore + dataToSubmit.settings = JSON.stringify(dataToSubmit.settings); + return updateMe({ variables: { input: dataToSubmit, @@ -92,6 +139,20 @@ export const UserInfo = () => {
    + {/* Celebrate completed issue */} +
    + ( + + )} + /> +
    +
    diff --git a/frontend/gql/__generated__/graphql.ts b/frontend/gql/__generated__/graphql.ts index 82872bd..f4f90c8 100644 --- a/frontend/gql/__generated__/graphql.ts +++ b/frontend/gql/__generated__/graphql.ts @@ -529,6 +529,7 @@ export type UpdateIssueInput = { export type UpdateMeInput = { firstName?: InputMaybe; lastName?: InputMaybe; + settings?: InputMaybe; }; export type UploadAssetInput = { @@ -545,6 +546,7 @@ export type User = { id?: Maybe; lastName?: Maybe; name?: Maybe; + settings?: Maybe; }; export type ViewState = { diff --git a/frontend/gql/gql-queries-mutations.ts b/frontend/gql/gql-queries-mutations.ts index e83e07c..e2013f3 100644 --- a/frontend/gql/gql-queries-mutations.ts +++ b/frontend/gql/gql-queries-mutations.ts @@ -91,6 +91,7 @@ export const USER_FIELDS = gql(/* GraphQL */ ` firstName lastName avatarUrl + settings } `); diff --git a/frontend/package.json b/frontend/package.json index b7773fc..f181a0b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -59,6 +59,7 @@ "framer-motion": "^10.16.16", "graphql": "^16.8.1", "highlight.js": "^11.9.0", + "js-confetti": "^0.12.0", "lodash": "^4.17.21", "lowlight": "^3.1.0", "luxon": "^3.4.4", @@ -69,6 +70,7 @@ "openai": "^4.23.0", "react": "^18.2.0", "react-avatar-edit": "^1.2.0", + "react-canvas-confetti": "^2.0.5", "react-dom": "^18.2.0", "react-emoji-render": "^2.0.1", "react-hook-form": "^7.49.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 41b02f8..63fdf1b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -131,6 +131,9 @@ dependencies: highlight.js: specifier: ^11.9.0 version: 11.9.0 + js-confetti: + specifier: ^0.12.0 + version: 0.12.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -161,6 +164,9 @@ dependencies: react-avatar-edit: specifier: ^1.2.0 version: 1.2.0(react@18.2.0) + react-canvas-confetti: + specifier: ^2.0.5 + version: 2.0.5(react@18.2.0) react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) @@ -2555,6 +2561,10 @@ packages: - subscriptions-transport-ws dev: true + /@types/canvas-confetti@1.6.4: + resolution: {integrity: sha512-fNyZ/Fdw/Y92X0vv7B+BD6ysHL4xVU5dJcgzgxLdGbn8O3PezZNIJpml44lKM0nsGur+o/6+NZbZeNTt00U1uA==} + dev: false + /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: false @@ -3373,6 +3383,10 @@ packages: /caniuse-lite@1.0.30001570: resolution: {integrity: sha512-+3e0ASu4sw1SWaoCtvPeyXp+5PsjigkSt8OXZbF9StH5pQWbxEjLAZE3n8Aup5udop1uRiKA7a4utUk/uoSpUw==} + /canvas-confetti@1.9.2: + resolution: {integrity: sha512-6Xi7aHHzKwxZsem4mCKoqP6YwUG3HamaHHAlz1hTNQPCqXhARFpSXnkC9TWlahHY5CG6hSL5XexNjxK8irVErg==} + dev: false + /capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} dependencies: @@ -5273,6 +5287,10 @@ packages: resolution: {integrity: sha512-GPExOkcMsCLBTi1YetY2LmkoY559fss0+0KVa6kOfb2YFe84nAM7Nm/XzuZozah4iHgmBGrCOHL5/cy670SBRw==} dev: true + /js-confetti@0.12.0: + resolution: {integrity: sha512-1R0Akxn3Zn82pMqW65N1V2NwKkZJ75bvBN/VAb36Ya0YHwbaSiAJZVRr/19HBxH/O8x2x01UFAbYI18VqlDN6g==} + dev: false + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -6547,6 +6565,16 @@ packages: react: 18.2.0 dev: false + /react-canvas-confetti@2.0.5(react@18.2.0): + resolution: {integrity: sha512-fL5WMHdNeLFqCMkgm0k4FzjPviEIv702zLl8GOaBonbBN0YG7YUsMyTV4OTEh+ukJBIs9082vh/L8s7yPeU+FA==} + peerDependencies: + react: '*' + dependencies: + '@types/canvas-confetti': 1.6.4 + canvas-confetti: 1.9.2 + react: 18.2.0 + dev: false + /react-css-styled@1.1.9: resolution: {integrity: sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==} dependencies: