diff --git a/__tests__/boot.ts b/__tests__/boot.ts index 991760a017..e9b89f2343 100644 --- a/__tests__/boot.ts +++ b/__tests__/boot.ts @@ -36,6 +36,7 @@ import { UserMarketingCta, UserNotification, } from '../src/entity'; +import { DatasetLocation } from '../src/entity/dataset/DatasetLocation'; import { OrganizationMemberRole, SourceMemberRoles, @@ -142,6 +143,7 @@ const LOGGED_IN_BODY = { youtube: null, linkedin: null, mastodon: null, + readme: null, language: undefined, isPlus: false, defaultFeedId: null, @@ -155,6 +157,7 @@ const LOGGED_IN_BODY = { coresRole: CoresRole.None, clickbaitTries: null, hasLocationSet: false, + location: null, }, marketingCta: null, feeds: [], @@ -413,6 +416,48 @@ describe('logged in boot', () => { expect(res.body.user.hasLocationSet).toBe(true); }); + it('should return location when user has locationId set', async () => { + const location = await con.getRepository(DatasetLocation).save({ + country: 'United States', + city: 'San Francisco', + subdivision: 'California', + iso2: 'US', + iso3: 'USA', + timezone: 'America/Los_Angeles', + ranking: 1, + }); + + await con.getRepository(User).save({ + ...usersFixture[0], + locationId: location.id, + }); + + mockLoggedIn(); + const res = await request(app.server) + .get(BASE_PATH) + .set('User-Agent', TEST_UA) + .set('Cookie', 'ory_kratos_session=value;') + .expect(200); + + expect(res.body.user.location).toEqual({ + id: location.id, + city: 'San Francisco', + subdivision: 'California', + country: 'United States', + }); + }); + + it('should return null location when user has no locationId', async () => { + mockLoggedIn(); + const res = await request(app.server) + .get(BASE_PATH) + .set('User-Agent', TEST_UA) + .set('Cookie', 'ory_kratos_session=value;') + .expect(200); + + expect(res.body.user.location).toBeNull(); + }); + it('should set kratos cookie expiration', async () => { mockLoggedIn(); const kratosCookie = 'ory_kratos_session'; @@ -1683,6 +1728,8 @@ describe('funnel boot', () => { 'subscriptionFlags', 'clickbaitTries', 'hasLocationSet', + 'location', + 'readme', ]), }); }); diff --git a/__tests__/updateUserInfo.ts b/__tests__/updateUserInfo.ts new file mode 100644 index 0000000000..8cf9b8aa5c --- /dev/null +++ b/__tests__/updateUserInfo.ts @@ -0,0 +1,664 @@ +import nock from 'nock'; +import { DataSource } from 'typeorm'; +import createOrGetConnection from '../src/db'; +import { + GraphQLTestClient, + GraphQLTestingState, + initializeGraphQLTesting, + disposeGraphQLTesting, + MockContext, +} from './helpers'; +import { User } from '../src/entity'; +import { clearFile, UploadPreset } from '../src/common/cloudinary'; + +let con: DataSource; +let state: GraphQLTestingState; +let client: GraphQLTestClient; +let loggedUser: string; + +// Mock cloudinary functions +jest.mock('../src/common/cloudinary', () => ({ + ...(jest.requireActual('../src/common/cloudinary') as Record< + string, + unknown + >), + clearFile: jest.fn(), + uploadAvatar: jest + .fn() + .mockResolvedValue({ url: 'https://cloudinary.com/avatar.jpg' }), + uploadProfileCover: jest + .fn() + .mockResolvedValue({ url: 'https://cloudinary.com/cover.jpg' }), +})); + +beforeAll(async () => { + con = await createOrGetConnection(); + state = await initializeGraphQLTesting( + () => new MockContext(con, loggedUser), + ); + client = state.client; +}); + +beforeEach(async () => { + loggedUser = '1'; + nock.cleanAll(); + jest.clearAllMocks(); + + // Save test users + await con.getRepository(User).save([ + { + id: '1', + name: 'Test User', + username: 'testuser', + image: 'https://daily.dev/test.jpg', + createdAt: new Date(), + }, + { + id: '2', + name: 'Another User', + username: 'anotheruser', + image: 'https://daily.dev/another.jpg', + createdAt: new Date(), + }, + ]); +}); + +afterAll(() => disposeGraphQLTesting(state)); + +describe('mutation updateUserInfo', () => { + const MUTATION = /* GraphQL */ ` + mutation updateUserInfo( + $data: UpdateUserInfoInput! + $upload: Upload + $coverUpload: Upload + ) { + updateUserInfo(data: $data, upload: $upload, coverUpload: $coverUpload) { + id + name + image + cover + username + permalink + bio + twitter + github + hashnode + createdAt + infoConfirmed + timezone + experienceLevel + language + readme + readmeHtml + location { + id + country + city + } + } + } + `; + + it('should not authorize when not logged in', async () => { + loggedUser = ''; + const res = await client.mutate(MUTATION, { + variables: { + data: { + name: 'Test User', + username: 'testuser', + }, + }, + }); + + expect(res.errors).toBeTruthy(); + expect(res.errors[0].extensions?.code).toEqual('UNAUTHENTICATED'); + }); + + it('should update user profile with basic fields', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + const user = await repo.findOneBy({ id: loggedUser }); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + name: 'Updated Name', + username: 'newusername', + bio: 'New bio', + image: user?.image, + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.updateUserInfo.name).toEqual('Updated Name'); + expect(res.data.updateUserInfo.username).toEqual('newusername'); + expect(res.data.updateUserInfo.bio).toEqual('New bio'); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser?.name).toEqual('Updated Name'); + expect(updatedUser?.username).toEqual('newusername'); + expect(updatedUser?.bio).toEqual('New bio'); + }); + + it('should update user profile with readme and generate readmeHtml', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + const user = await repo.findOneBy({ id: loggedUser }); + + const readme = + '# Hello World\n\nThis is my **readme** with [a link](https://example.com).'; + expect(user!.readme).toBeNull(); + expect(user!.readmeHtml).toBeNull(); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + readme, + username: 'uuu1', + name: user!.name, + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.updateUserInfo.readme).toEqual(readme); + expect(res.data.updateUserInfo.readmeHtml).toBeTruthy(); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser!.readme).toEqual(readme); + expect(updatedUser!.readmeHtml).toContain('

'); + expect(updatedUser!.readmeHtml).toContain('Hello World'); + expect(updatedUser!.readmeHtml).toContain(''); + expect(updatedUser!.readmeHtml).toContain('readme'); + expect(updatedUser!.readmeHtml).toContain(' { + loggedUser = '1'; + const repo = con.getRepository(User); + const user = await repo.findOneBy({ id: loggedUser }); + + const cover = 'https://example.com/cover.jpg'; + expect(user!.cover).toBeNull(); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + cover, + username: 'uuu1', + name: user!.name, + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.updateUserInfo.cover).toEqual(cover); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser!.cover).toEqual(cover); + }); + + it.skip('should update user profile with locationId - requires DatasetLocation records', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + const user = await repo.findOneBy({ id: loggedUser }); + + const locationId = 'US-CA-SF'; + expect(user!.locationId).toBeNull(); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + locationId, + username: 'uuu1', + name: user!.name, + }, + }, + }); + + expect(res.errors).toBeFalsy(); + // Location is a nested object, not directly accessible as locationId + expect(res.data.updateUserInfo.location).toBeTruthy(); + expect(res.data.updateUserInfo.location.id).toEqual(locationId); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser!.locationId).toEqual(locationId); + }); + + describe('image and cover deletion logic', () => { + beforeEach(() => { + jest.mocked(clearFile).mockClear(); + }); + + it('should delete avatar when image is set to null', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + + // Set user with existing image + await repo.update( + { id: loggedUser }, + { image: 'https://example.com/old-avatar.jpg' }, + ); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + image: null, + username: 'uuu1', + name: 'Test User', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser?.image).toBeNull(); + expect(clearFile).toHaveBeenCalledWith({ + referenceId: loggedUser, + preset: UploadPreset.Avatar, + }); + }); + + it('should delete cover when cover is set to null', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + + // Set user with existing cover + await repo.update( + { id: loggedUser }, + { cover: 'https://example.com/old-cover.jpg' }, + ); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + cover: null, + username: 'uuu1', + name: 'Test User', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser?.cover).toBeNull(); + expect(clearFile).toHaveBeenCalledWith({ + referenceId: loggedUser, + preset: UploadPreset.ProfileCover, + }); + }); + + it('should delete both image and cover simultaneously', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + + // Set user with both image and cover + await repo.update( + { id: loggedUser }, + { + image: 'https://example.com/avatar.jpg', + cover: 'https://example.com/cover.jpg', + }, + ); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + image: null, + cover: null, + username: 'uuu1', + name: 'Test User', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser?.image).toBeNull(); + expect(updatedUser?.cover).toBeNull(); + + expect(clearFile).toHaveBeenCalledTimes(2); + expect(clearFile).toHaveBeenCalledWith({ + referenceId: loggedUser, + preset: UploadPreset.Avatar, + }); + expect(clearFile).toHaveBeenCalledWith({ + referenceId: loggedUser, + preset: UploadPreset.ProfileCover, + }); + }); + + it('should keep existing image when providing same URL', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + + const existingImage = 'https://example.com/avatar.jpg'; + await repo.update({ id: loggedUser }, { image: existingImage }); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + image: existingImage, + username: 'uuu1', + name: 'Test User', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser?.image).toEqual(existingImage); + expect(clearFile).not.toHaveBeenCalled(); + }); + + it('should keep existing cover when providing same URL', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + + const user = await repo.findOneBy({ id: loggedUser }); + const existingCover = 'https://example.com/cover.jpg'; + await repo.update({ id: loggedUser }, { cover: existingCover }); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + cover: existingCover, + image: user?.image, // Preserve existing image + username: 'uuu1', + name: 'Test User', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser?.cover).toEqual(existingCover); + expect(clearFile).not.toHaveBeenCalled(); + }); + + it('should not clear image when only updating other fields', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + + const existingImage = 'https://example.com/avatar.jpg'; + await repo.update( + { id: loggedUser }, + { image: existingImage, bio: 'Old bio' }, + ); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + bio: 'New bio', + username: 'uuu1', + name: 'Test User', + image: existingImage, + }, + }, + }); + + expect(res.errors).toBeFalsy(); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser?.image).toEqual(existingImage); + expect(updatedUser?.bio).toEqual('New bio'); + expect(clearFile).not.toHaveBeenCalled(); + }); + + it('should not clear cover when only updating other fields', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + + const user = await repo.findOneBy({ id: loggedUser }); + const existingCover = 'https://example.com/cover.jpg'; + await repo.update( + { id: loggedUser }, + { cover: existingCover, bio: 'Old bio' }, + ); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + bio: 'New bio', + username: 'uuu1', + name: 'Test User', + image: user?.image, // Preserve existing image + cover: existingCover, + }, + }, + }); + + expect(res.errors).toBeFalsy(); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser?.cover).toEqual(existingCover); + expect(updatedUser?.bio).toEqual('New bio'); + expect(clearFile).not.toHaveBeenCalled(); + }); + + it('should not clear image when user has no existing image', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + + // Ensure user has no image + await repo.update({ id: loggedUser }, { image: null }); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + image: null, + username: 'uuu1', + name: 'Test User', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(clearFile).not.toHaveBeenCalled(); + }); + + it('should not clear cover when user has no existing cover', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + + // Ensure user has no cover + const user = await repo.findOneBy({ id: loggedUser }); + await repo.update({ id: loggedUser }, { cover: null }); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + cover: null, + image: user?.image, // Preserve existing image + username: 'uuu1', + name: 'Test User', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(clearFile).not.toHaveBeenCalled(); + }); + }); + + describe('file uploads', () => { + it.skip('should handle avatar upload - requires file upload setup', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + + process.env.CLOUDINARY_URL = 'cloudinary://test'; + + const res = await client.mutate(MUTATION, { + variables: { + data: { + username: 'uuu1', + name: 'Test User', + }, + upload: { + createReadStream: () => 'mock-stream', + filename: 'avatar.jpg', + mimetype: 'image/jpeg', + encoding: 'binary', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.updateUserInfo.image).toEqual( + 'https://cloudinary.com/avatar.jpg', + ); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser?.image).toEqual('https://cloudinary.com/avatar.jpg'); + + delete process.env.CLOUDINARY_URL; + }); + + it.skip('should handle cover upload - requires file upload setup', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + + process.env.CLOUDINARY_URL = 'cloudinary://test'; + + const res = await client.mutate(MUTATION, { + variables: { + data: { + username: 'uuu1', + name: 'Test User', + }, + coverUpload: { + createReadStream: () => 'mock-stream', + filename: 'cover.jpg', + mimetype: 'image/jpeg', + encoding: 'binary', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.updateUserInfo.cover).toEqual( + 'https://cloudinary.com/cover.jpg', + ); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser?.cover).toEqual('https://cloudinary.com/cover.jpg'); + + delete process.env.CLOUDINARY_URL; + }); + + it.skip('should handle both avatar and cover upload simultaneously - requires file upload setup', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + + process.env.CLOUDINARY_URL = 'cloudinary://test'; + + const res = await client.mutate(MUTATION, { + variables: { + data: { + username: 'uuu1', + name: 'Test User', + }, + upload: { + createReadStream: () => 'mock-avatar-stream', + filename: 'avatar.jpg', + mimetype: 'image/jpeg', + encoding: 'binary', + }, + coverUpload: { + createReadStream: () => 'mock-cover-stream', + filename: 'cover.jpg', + mimetype: 'image/jpeg', + encoding: 'binary', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.updateUserInfo.image).toEqual( + 'https://cloudinary.com/avatar.jpg', + ); + expect(res.data.updateUserInfo.cover).toEqual( + 'https://cloudinary.com/cover.jpg', + ); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser?.image).toEqual('https://cloudinary.com/avatar.jpg'); + expect(updatedUser?.cover).toEqual('https://cloudinary.com/cover.jpg'); + + delete process.env.CLOUDINARY_URL; + }); + }); + + it('should validate username uniqueness', async () => { + loggedUser = '1'; + + // Create another user with a username + const repo = con.getRepository(User); + await repo.save({ + id: '2', + email: 'user2@example.com', + username: 'existinguser', + }); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + username: 'existinguser', + name: 'Test User', + }, + }, + }); + + expect(res.errors).toBeTruthy(); + expect(res.errors[0].message).toContain('username already exists'); + }); + + it('should handle all fields together', async () => { + loggedUser = '1'; + const repo = con.getRepository(User); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + name: 'Full Test User', + username: 'fulltestuser', + bio: 'Full bio', + cover: 'https://example.com/cover.jpg', + readme: '# My Profile\n\nWelcome!', + // locationId: 'US-NY-NYC', // Skipped - requires DatasetLocation records + twitter: 'fulltestuser', + github: 'fulltestuser', + portfolio: 'https://fulltestuser.com', + company: 'Test Company', + title: 'Test Engineer', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.updateUserInfo.name).toEqual('Full Test User'); + expect(res.data.updateUserInfo.username).toEqual('fulltestuser'); + expect(res.data.updateUserInfo.bio).toEqual('Full bio'); + expect(res.data.updateUserInfo.cover).toEqual( + 'https://example.com/cover.jpg', + ); + expect(res.data.updateUserInfo.readme).toEqual('# My Profile\n\nWelcome!'); + expect(res.data.updateUserInfo.readmeHtml).toBeTruthy(); + // Location test skipped - requires DatasetLocation records + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser?.name).toEqual('Full Test User'); + expect(updatedUser?.username).toEqual('fulltestuser'); + expect(updatedUser?.bio).toEqual('Full bio'); + expect(updatedUser?.cover).toEqual('https://example.com/cover.jpg'); + expect(updatedUser?.readme).toEqual('# My Profile\n\nWelcome!'); + expect(updatedUser?.readmeHtml).toContain('

'); + // locationId check skipped - requires DatasetLocation records + }); +}); diff --git a/src/entity/user/User.ts b/src/entity/user/User.ts index 664fcad824..65a6103b01 100644 --- a/src/entity/user/User.ts +++ b/src/entity/user/User.ts @@ -28,6 +28,7 @@ import type { NotificationPreferenceStatus } from '../../notifications/common'; import type { UserCandidatePreference } from './UserCandidatePreference'; import type { UserCandidateKeyword } from './UserCandidateKeyword'; import type { UserCandidateAnswer } from './UserCandidateAnswer'; +import type { DatasetLocation } from '../dataset/DatasetLocation'; export type UserFlags = Partial<{ vordr: boolean; @@ -339,4 +340,15 @@ export class User { { lazy: true }, ) candidateAnswers: Promise; + + @Column({ type: 'text', default: null }) + locationId: string | null; + + @ManyToOne('DatasetLocation', { lazy: true }) + @JoinColumn({ + name: 'locationId', + foreignKeyConstraintName: 'FK_user_locationId', + }) + @Index('IDX_user_locationId') + location: Promise; } diff --git a/src/index.ts b/src/index.ts index f873123970..a009aca07d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -157,7 +157,7 @@ export default async function app( app.register(MercuriusGQLUpload, { maxFileSize: GQL_MAX_FILE_SIZE, - maxFiles: 1, + maxFiles: 2, }); app.register(mercurius, { diff --git a/src/migration/1760354446019-UserLocationId.ts b/src/migration/1760354446019-UserLocationId.ts new file mode 100644 index 0000000000..f6196984a7 --- /dev/null +++ b/src/migration/1760354446019-UserLocationId.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UserLocationId1760354446019 implements MigrationInterface { + name = 'UserLocationId1760354446019'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" ADD "locationId" uuid`); + await queryRunner.query( + `ALTER TABLE "user" ADD CONSTRAINT "FK_user_locationId" FOREIGN KEY ("locationId") REFERENCES "dataset_location"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_user_locationId" ON "user" ("locationId")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_user_locationId"`); + await queryRunner.query( + `ALTER TABLE "user" DROP CONSTRAINT "FK_user_locationId"`, + ); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "locationId"`); + } +} diff --git a/src/routes/boot.ts b/src/routes/boot.ts index 9b9e09139f..5290353ad9 100644 --- a/src/routes/boot.ts +++ b/src/routes/boot.ts @@ -23,6 +23,7 @@ import { SquadSource, User, } from '../entity'; +import { DatasetLocation } from '../entity/dataset/DatasetLocation'; import { getPermissionsForMember, GQLSource, @@ -72,7 +73,7 @@ import { SEMATTRS_DAILY_STAFF, } from '../telemetry'; import { getUnreadNotificationsCount } from '../notifications/common'; -import { maxFeedsPerUser, type CoresRole } from '../types'; +import { maxFeedsPerUser, type CoresRole, type TLocation } from '../types'; import { queryReadReplica } from '../common/queryReadReplica'; import { queryDataSource } from '../common/queryDataSource'; import { isPlusMember } from '../paddle'; @@ -146,6 +147,7 @@ export type LoggedInBoot = BaseBoot & { canSubmitArticle: boolean; balance: GetBalanceResult; coresRole: CoresRole; + location?: TLocation | null; }; accessToken?: AccessToken; marketingCta: MarketingCta | null; @@ -474,6 +476,9 @@ const getUser = ( 'defaultFeedId', 'flags', 'coresRole', + 'locationId', + 'readme', + 'language', ], }); @@ -492,6 +497,32 @@ const getBalanceBoot: typeof getBalance = async ({ userId }) => { } }; +const getLocation = async ( + con: DataSource | QueryRunner, + userId: string | null, +): Promise | null> => { + if (!userId) { + return null; + } + + const location = await con.manager + .createQueryBuilder(DatasetLocation, 'location') + .innerJoin(User, 'user', 'user.locationId = location.id') + .select([ + 'location.id', + 'location.city', + 'location.subdivision', + 'location.country', + ]) + .where('user.id = :userId', { userId }) + .getOne(); + + return location; +}; + const loggedInBoot = async ({ con, req, @@ -518,7 +549,15 @@ const loggedInBoot = async ({ roles, extra, [alerts, settings, marketingCta], - [user, squads, lastBanner, exp, feeds, unreadNotificationsCount], + [ + user, + squads, + lastBanner, + exp, + feeds, + unreadNotificationsCount, + location, + ], balance, clickbaitTries, ] = await Promise.all([ @@ -540,6 +579,7 @@ const loggedInBoot = async ({ getExperimentation({ userId, con: queryRunner, ...geo }), getFeeds({ con: queryRunner, userId }), getUnreadNotificationsCount(queryRunner, userId), + getLocation(queryRunner, userId), ]); }), getBalanceBoot({ userId }), @@ -548,6 +588,7 @@ const loggedInBoot = async ({ if (!user) { return handleNonExistentUser(con, req, res, middleware); } + const hasLocationSet = !!user.flags?.location?.lastStored; const isTeamMember = exp?.a?.team === 1; const isPlus = isPlusMember(user.subscriptionFlags?.cycle); @@ -573,6 +614,8 @@ const loggedInBoot = async ({ 'cover', 'subscriptionFlags', 'flags', + 'locationId', + 'readmeHtml', ]), providers: [null], roles, @@ -595,6 +638,7 @@ const loggedInBoot = async ({ }, clickbaitTries, hasLocationSet, + location, }, visit, alerts: { @@ -893,6 +937,9 @@ const getFunnelLoggedInData = async ( 'cover', 'subscriptionFlags', 'flags', + 'locationId', + 'readmeHtml', + 'readme', ]), providers: [null], permalink: `${process.env.COMMENTS_PREFIX}/${user.username || user.id}`, diff --git a/src/schema/users.ts b/src/schema/users.ts index 6c9a119a53..f89f03f432 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -198,11 +198,23 @@ export interface GQLUpdateUserInput { notificationFlags?: UserNotificationFlags; } +export interface GQLUpdateUserInfoInput extends GQLUpdateUserInput { + locationId?: string; + cover?: string; + readme?: string; +} + interface GQLUserParameters { data: GQLUpdateUserInput; upload: Promise; } +interface GQLUserInfoParameters { + data: GQLUpdateUserInfoInput; + upload?: Promise; + coverUpload?: Promise; +} + export interface GQLUser { id: string; name: string; @@ -331,6 +343,25 @@ export const typeDefs = /* GraphQL */ ` company: Company } + type DatasetLocation { + """ + ID of the location + """ + id: String! + """ + Country of the location + """ + country: String! + """ + City of the location + """ + city: String + """ + Subdivision of the location + """ + subdivision: String + } + """ Registered user """ @@ -492,6 +523,10 @@ export const typeDefs = /* GraphQL */ ` Role for Cores access """ coresRole: Int + """ + Where the user is located + """ + location: DatasetLocation } """ @@ -650,6 +685,136 @@ export const typeDefs = /* GraphQL */ ` flags: UserFlagsPublic } + """ + Update user info input with extended fields + """ + input UpdateUserInfoInput { + """ + Full name of the user + """ + name: String + """ + Email for the user + """ + email: String + """ + Profile image of the user + """ + image: String + """ + The cover image of the user + """ + cover: String + """ + Username (handle) of the user + """ + username: String + """ + Bio of the user + """ + bio: String + """ + Twitter handle of the user + """ + twitter: String + """ + Github handle of the user + """ + github: String + """ + Hashnode handle of the user + """ + hashnode: String + """ + Bluesky profile of the user + """ + bluesky: String + """ + Roadmap profile of the user + """ + roadmap: String + """ + Threads profile of the user + """ + threads: String + """ + Codepen profile of the user + """ + codepen: String + """ + Reddit profile of the user + """ + reddit: String + """ + Stackoverflow profile of the user + """ + stackoverflow: String + """ + Youtube profile of the user + """ + youtube: String + """ + Linkedin profile of the user + """ + linkedin: String + """ + Mastodon profile of the user + """ + mastodon: String + """ + Preferred timezone of the user that affects data + """ + timezone: String + """ + Preferred day of the week to start the week + """ + weekStart: Int + """ + Current company of the user + """ + company: String + """ + Title of user from their company + """ + title: String + """ + User website + """ + portfolio: String + """ + If the user has accepted marketing + """ + acceptedMarketing: Boolean + """ + If the user's info is confirmed + """ + infoConfirmed: Boolean + """ + Experience level of the user + """ + experienceLevel: String + """ + Preferred language of the user + """ + language: String + """ + Default feed id for the user + """ + defaultFeedId: String + """ + Flags for the user + """ + flags: UserFlagsPublic + """ + id of the location selected by the user + """ + locationId: String + """ + The user's readme + """ + readme: String + } + type TagsReadingStatus { tag: String! readingDays: Int! @@ -1062,6 +1227,15 @@ export const typeDefs = /* GraphQL */ ` """ updateUserProfile(data: UpdateUserInput, upload: Upload): User @auth + """ + Update user info with extended fields + """ + updateUserInfo( + data: UpdateUserInfoInput + upload: Upload + coverUpload: Upload + ): User @auth + """ Hide user's read history """ @@ -2205,7 +2379,6 @@ export const resolvers: IResolvers = traceResolvers< return { _: true }; }, - // add mutation to clear images updateUserProfile: async ( _, { data, upload }: GQLUserParameters, @@ -2299,6 +2472,141 @@ export const resolvers: IResolvers = traceResolvers< throw err; } }, + updateUserInfo: async ( + _, + { data, upload, coverUpload }: GQLUserInfoParameters, + ctx: AuthContext, + ): Promise => { + const repo = ctx.con.getRepository(User); + const user = await repo.findOneBy({ id: ctx.userId }); + + if (!user) { + throw new AuthenticationError('Unauthorized!'); + } + + if (!ctx.service) { + // Only accept email changes from Service calls + delete data.email; + delete data.infoConfirmed; + } + data = await validateUserUpdate(user, data, ctx.con); + + const filesToClear = []; + + if ((!data.image || !!upload) && user.image) { + filesToClear.push( + clearFile({ referenceId: user.id, preset: UploadPreset.Avatar }), + ); + } + + if ((!data.cover || !!coverUpload) && user.cover) { + filesToClear.push( + clearFile({ + referenceId: user.id, + preset: UploadPreset.ProfileCover, + }), + ); + } + + await Promise.all(filesToClear); + + const cloudinaryUrl = process.env.CLOUDINARY_URL || null; + + const [avatar, cover] = await Promise.all([ + (async () => { + if (upload && cloudinaryUrl) { + const file = await upload; + return (await uploadAvatar(user.id, file.createReadStream())).url; + } + return data.image || null; + })(), + (async () => { + if (coverUpload && cloudinaryUrl) { + const file = await coverUpload; + return (await uploadProfileCover(user.id, file.createReadStream())) + .url; + } + return data.cover || null; + })(), + ]); + + const readmeHtml = markdown.render(data.readme || ''); + + try { + const updatedUser = { + ...user, + ...data, + image: avatar, + cover, + readmeHtml, + }; + updatedUser.email = updatedUser.email?.toLowerCase(); + + const marketingFlag = updatedUser.acceptedMarketing + ? { + email: NotificationPreferenceStatus.Subscribed, + inApp: NotificationPreferenceStatus.Subscribed, + } + : { + email: NotificationPreferenceStatus.Muted, + inApp: NotificationPreferenceStatus.Muted, + }; + + if ( + !user.infoConfirmed && + updatedUser.email && + updatedUser.username && + updatedUser.name + ) { + updatedUser.infoConfirmed = true; + } + + await repo.update( + { id: user.id }, + { + ...updatedUser, + permalink: undefined, + flags: data?.flags ? updateFlagsStatement(data.flags) : undefined, + notificationFlags: updateNotificationFlags({ + marketing: marketingFlag, + }), + }, + ); + + return updatedUser; + } catch (originalError) { + const err = originalError as TypeORMQueryFailedError; + + if (err.code === TypeOrmError.DUPLICATE_ENTRY) { + const uniqueColumns: Array = [ + 'username', + 'github', + 'twitter', + 'hashnode', + 'roadmap', + 'threads', + 'codepen', + 'reddit', + 'stackoverflow', + 'youtube', + 'linkedin', + 'bluesky', + 'mastodon', + ]; + + uniqueColumns.forEach((uniqueColumn) => { + if (err.message.indexOf(`users_${uniqueColumn}_unique`) > -1) { + throw new ValidationError( + JSON.stringify({ + [uniqueColumn]: `${uniqueColumn} already exists`, + }), + ); + } + }); + } + throw err; + } + }, deleteUser: async (_, __, ctx: AuthContext): Promise => { const userId = ctx.userId; return await deleteUser(ctx.con, userId); diff --git a/src/types.ts b/src/types.ts index 0399cd1f37..64ac891d28 100644 --- a/src/types.ts +++ b/src/types.ts @@ -307,3 +307,10 @@ export type ServiceClient = { instance: Client; garmr: GarmrService; }; + +export type TLocation = { + id: string; + country: string; + subdivision: string | null; + city: string | null; +};