diff --git a/__tests__/__snapshots__/users.ts.snap b/__tests__/__snapshots__/users.ts.snap index abe2dfd428..753b22cfbe 100644 --- a/__tests__/__snapshots__/users.ts.snap +++ b/__tests__/__snapshots__/users.ts.snap @@ -41,6 +41,7 @@ Array [ exports[`mutation updateUserProfile should update user profile 1`] = ` Object { "bio": null, + "cover": null, "createdAt": Any, "experienceLevel": null, "github": null, @@ -51,6 +52,8 @@ Object { "language": null, "name": "Ido", "permalink": "http://localhost:5002/aaa1", + "readme": null, + "readmeHtml": "", "timezone": "Europe/London", "twitter": null, "username": "aaa1", diff --git a/__tests__/boot.ts b/__tests__/boot.ts index 991760a017..f1f0afc0a4 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,7 @@ describe('funnel boot', () => { 'subscriptionFlags', 'clickbaitTries', 'hasLocationSet', + 'location', ]), }); }); diff --git a/__tests__/users.ts b/__tests__/users.ts index a6d13f1f1f..2b845c7349 100644 --- a/__tests__/users.ts +++ b/__tests__/users.ts @@ -83,6 +83,7 @@ import { updateSubscriptionFlags, UploadPreset, } from '../src/common'; +import { clearFile } from '../src/common/cloudinary'; import { DataSource, In, IsNull } from 'typeorm'; import createOrGetConnection from '../src/db'; import request from 'supertest'; @@ -195,6 +196,14 @@ jest.mock('../src/cio', () => ({ syncNotificationFlagsToCio: jest.fn(), })); +jest.mock('../src/common/cloudinary', () => ({ + ...(jest.requireActual('../src/common/cloudinary') as Record< + string, + unknown + >), + clearFile: jest.fn(), +})); + beforeAll(async () => { con = await createOrGetConnection(); state = await initializeGraphQLTesting( @@ -3657,7 +3666,15 @@ describe('mutation updateUserProfile', () => { const user = await repo.findOneBy({ id: loggedUser }); const timezone = 'Europe/London'; const res = await client.mutate(MUTATION, { - variables: { data: { timezone, username: 'aaa1', name: 'Ido' } }, + variables: { + data: { + timezone, + username: 'aaa1', + name: 'Ido', + image: user?.image, + cover: user?.cover, + }, + }, }); expect(res.errors?.length).toBeFalsy(); @@ -3767,6 +3784,97 @@ describe('mutation updateUserProfile', () => { expect(updatedUser!.language).toEqual(language); }); + it('should update user profile and set readme with generated 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?.length).toBeFalsy(); + expect(res.data.updateUserProfile.readme).toEqual(readme); + expect(res.data.updateUserProfile.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?.length).toBeFalsy(); + expect(res.data.updateUserProfile.cover).toEqual(cover); + + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser!.cover).toEqual(cover); + }); + + it('should handle coverUpload parameter when provided', async () => { + loggedUser = '1'; + + const repo = con.getRepository(User); + const user = await repo.findOneBy({ id: loggedUser }); + expect(user!.cover).toBeNull(); + + // Test with coverUpload and a cover URL fallback (no CLOUDINARY_URL) + // When CLOUDINARY_URL is not set, it should use data.cover fallback + const coverUrl = 'https://example.com/uploaded-cover.jpg'; + const res = await authorizeRequest( + request(app.server) + .post('/graphql') + .field( + 'operations', + JSON.stringify({ + query: MUTATION, + variables: { + data: { username: 'uuu1', name: user!.name, cover: coverUrl }, + upload: null, + coverUpload: null, + }, + }), + ) + .field('map', JSON.stringify({ '0': ['variables.coverUpload'] })) + .attach('0', './__tests__/fixture/happy_card.png'), + loggedUser, + ).expect(200); + + // Verify the response + const body = res.body; + expect(body.errors).toBeFalsy(); + expect(body.data.updateUserProfile.cover).toEqual(coverUrl); + + // Verify the cover was saved to database + const updatedUser = await repo.findOneBy({ id: loggedUser }); + expect(updatedUser!.cover).toEqual(coverUrl); + }); + it('should not update user profile if language is invalid', async () => { loggedUser = '1'; @@ -4197,6 +4305,275 @@ describe('mutation updateUserProfile', () => { expect(updated?.flags.vordr).toBe(true); expect(updated?.flags.trustScore).toBe(0.9); }); + + 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', cover: null }, + ); + + 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(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', image: null }, + ); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + cover: null, + image: 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 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, cover: null }, + ); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + image: existingImage, + cover: null, + 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 existingCover = 'https://example.com/cover.jpg'; + await repo.update( + { id: loggedUser }, + { cover: existingCover, image: null }, + ); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + cover: existingCover, + image: null, + 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 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 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, cover: null, bio: 'Old bio' }, + ); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + bio: 'New bio', + username: 'uuu1', + name: 'Test User', + image: existingImage, + cover: null, + }, + }, + }); + + 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 existingCover = 'https://example.com/cover.jpg'; + await repo.update( + { id: loggedUser }, + { cover: existingCover, image: null, bio: 'Old bio' }, + ); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + bio: 'New bio', + username: 'uuu1', + name: 'Test User', + image: null, + 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, cover: null }); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + image: null, + cover: 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 + await repo.update({ id: loggedUser }, { cover: null, image: null }); + + const res = await client.mutate(MUTATION, { + variables: { + data: { + cover: null, + image: null, + username: 'uuu1', + name: 'Test User', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(clearFile).not.toHaveBeenCalled(); + }); + }); }); describe('mutation deleteUser', () => { 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..6b96f1a5f4 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,7 @@ const getUser = ( 'defaultFeedId', 'flags', 'coresRole', + 'locationId', ], }); @@ -492,6 +495,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 +547,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 +577,7 @@ const loggedInBoot = async ({ getExperimentation({ userId, con: queryRunner, ...geo }), getFeeds({ con: queryRunner, userId }), getUnreadNotificationsCount(queryRunner, userId), + getLocation(queryRunner, userId), ]); }), getBalanceBoot({ userId }), @@ -548,6 +586,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 +612,8 @@ const loggedInBoot = async ({ 'cover', 'subscriptionFlags', 'flags', + 'locationId', + 'readmeHtml', ]), providers: [null], roles, @@ -595,6 +636,7 @@ const loggedInBoot = async ({ }, clickbaitTries, hasLocationSet, + location, }, visit, alerts: { @@ -893,6 +935,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..3208b08e16 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -196,11 +196,15 @@ export interface GQLUpdateUserInput { defaultFeedId?: string; flags: UserFlagsPublic; notificationFlags?: UserNotificationFlags; + locationId?: string; + cover?: string; + readme?: string; } interface GQLUserParameters { data: GQLUpdateUserInput; upload: Promise; + coverUpload?: Promise; } export interface GQLUser { @@ -331,6 +335,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 +515,10 @@ export const typeDefs = /* GraphQL */ ` Role for Cores access """ coresRole: Int + """ + Where the user is located + """ + location: DatasetLocation } """ @@ -549,6 +576,10 @@ export const typeDefs = /* GraphQL */ ` """ image: String """ + The cover image of the user + """ + cover: String + """ Username (handle) of the user """ username: String @@ -648,6 +679,14 @@ export const typeDefs = /* GraphQL */ ` Flags for the user """ flags: UserFlagsPublic + """ + id of the location selected by the user + """ + locationId: String + """ + The user's readme + """ + readme: String } type TagsReadingStatus { @@ -1060,7 +1099,11 @@ export const typeDefs = /* GraphQL */ ` """ Update user profile information """ - updateUserProfile(data: UpdateUserInput, upload: Upload): User @auth + updateUserProfile( + data: UpdateUserInput + upload: Upload + coverUpload: Upload + ): User @auth """ Hide user's read history @@ -2208,7 +2251,7 @@ export const resolvers: IResolvers = traceResolvers< // add mutation to clear images updateUserProfile: async ( _, - { data, upload }: GQLUserParameters, + { data, upload, coverUpload }: GQLUserParameters, ctx: AuthContext, ): Promise => { const repo = ctx.con.getRepository(User); @@ -2225,13 +2268,55 @@ export const resolvers: IResolvers = traceResolvers< } data = await validateUserUpdate(user, data, ctx.con); - const avatar = - !!upload && process.env.CLOUDINARY_URL - ? (await uploadAvatar(user.id, (await upload).createReadStream())).url - : data.image || user.image; + 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 }; + const updatedUser = { + ...user, + ...data, + image: avatar, + cover, + readmeHtml, + }; updatedUser.email = updatedUser.email?.toLowerCase(); const marketingFlag = updatedUser.acceptedMarketing 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; +};