From 692c7781bc6675a56909a7983e76f582bbb1cc4a Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Mon, 13 Oct 2025 16:54:44 +0200 Subject: [PATCH 01/12] update image logic --- __tests__/boot.ts | 45 +++ __tests__/users.ts | 379 +++++++++++++++++- src/entity/user/User.ts | 7 + src/migration/1760354446019-UserLocationId.ts | 19 + src/routes/boot.ts | 45 ++- src/schema/users.ts | 49 ++- src/types.ts | 7 + 7 files changed, 544 insertions(+), 7 deletions(-) create mode 100644 src/migration/1760354446019-UserLocationId.ts diff --git a/__tests__/boot.ts b/__tests__/boot.ts index ea95db1a1f..1bc3defe90 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, @@ -154,6 +155,7 @@ const LOGGED_IN_BODY = { coresRole: CoresRole.None, clickbaitTries: null, hasLocationSet: false, + location: null, }, marketingCta: null, feeds: [], @@ -412,6 +414,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'; @@ -1682,6 +1726,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..5bcf3bb884 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,10 @@ export class User { { lazy: true }, ) candidateAnswers: Promise; + + @Column({ type: 'text', default: null }) + locationId: string | null; + + @ManyToOne('DatasetLocation', { lazy: true }) + location: Promise; } diff --git a/src/migration/1760354446019-UserLocationId.ts b/src/migration/1760354446019-UserLocationId.ts new file mode 100644 index 0000000000..dd18a9ae4c --- /dev/null +++ b/src/migration/1760354446019-UserLocationId.ts @@ -0,0 +1,19 @@ +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`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + 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..c781442206 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 Location } 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?: Location | 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,7 @@ const loggedInBoot = async ({ 'cover', 'subscriptionFlags', 'flags', + 'locationId', ]), providers: [null], roles, @@ -595,6 +635,7 @@ const loggedInBoot = async ({ }, clickbaitTries, hasLocationSet, + location, }, visit, alerts: { diff --git a/src/schema/users.ts b/src/schema/users.ts index 6c9a119a53..36f7f2dbd0 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -196,6 +196,7 @@ export interface GQLUpdateUserInput { defaultFeedId?: string; flags: UserFlagsPublic; notificationFlags?: UserNotificationFlags; + locationId?: string; } interface GQLUserParameters { @@ -648,6 +649,10 @@ export const typeDefs = /* GraphQL */ ` Flags for the user """ flags: UserFlagsPublic + """ + id of the location selected by the user + """ + locationId: String } type TagsReadingStatus { @@ -2225,10 +2230,46 @@ 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 }; diff --git a/src/types.ts b/src/types.ts index 1ba189c024..6516a5e67d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -304,3 +304,10 @@ export type ServiceClient = { instance: Client; garmr: GarmrService; }; + +export type Location = { + id: string; + country: string; + subdivision: string | null; + city: string | null; +}; From 50895a6241346c85c426340684f180496bfe4204 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Mon, 13 Oct 2025 17:27:38 +0200 Subject: [PATCH 02/12] add readme to return --- __tests__/boot.ts | 1 + src/routes/boot.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/__tests__/boot.ts b/__tests__/boot.ts index 1bc3defe90..f32077b265 100644 --- a/__tests__/boot.ts +++ b/__tests__/boot.ts @@ -142,6 +142,7 @@ const LOGGED_IN_BODY = { youtube: null, linkedin: null, mastodon: null, + readme: null, language: undefined, isPlus: false, defaultFeedId: null, diff --git a/src/routes/boot.ts b/src/routes/boot.ts index c781442206..20208ddf6c 100644 --- a/src/routes/boot.ts +++ b/src/routes/boot.ts @@ -477,6 +477,7 @@ const getUser = ( 'flags', 'coresRole', 'locationId', + 'readme', ], }); @@ -613,6 +614,7 @@ const loggedInBoot = async ({ 'subscriptionFlags', 'flags', 'locationId', + 'readmeHtml', ]), providers: [null], roles, @@ -934,6 +936,8 @@ const getFunnelLoggedInData = async ( 'cover', 'subscriptionFlags', 'flags', + 'locationId', + 'readmeHtml', ]), providers: [null], permalink: `${process.env.COMMENTS_PREFIX}/${user.username || user.id}`, From 2005b420cfb59a65e162e82df1575a0544e12cf2 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Mon, 13 Oct 2025 19:43:49 +0200 Subject: [PATCH 03/12] update user stats --- __tests__/__snapshots__/users.ts.snap | 3 +++ src/schema/users.ts | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) 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/src/schema/users.ts b/src/schema/users.ts index 36f7f2dbd0..cf3f2cd9f2 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -250,7 +250,7 @@ type FollowStats = { numFollowing: number; numFollowers: number }; export type GQLUserStats = Omit & CommentStats & - FollowStats; + FollowStats & { reputation?: number }; export interface GQLReadingRank { rankThisWeek?: number; @@ -669,6 +669,7 @@ export const typeDefs = /* GraphQL */ ` numCommentUpvotes: Int numFollowers: Int numFollowing: Int + reputation: Int } type ReadingRank { @@ -1512,7 +1513,7 @@ export const resolvers: IResolvers = traceResolvers< { id }: { id: string }, ctx: Context, ): Promise => { - const [postStats, commentStats, numFollowing, numFollowers] = + const [postStats, commentStats, numFollowing, numFollowers, user] = await Promise.all([ getAuthorPostStats(ctx.con, id), ctx.con @@ -1546,6 +1547,10 @@ export const resolvers: IResolvers = traceResolvers< }) .andWhere('cp."feedId" = cp."userId"') .getCount(), + ctx.con.getRepository(User).findOne({ + where: { id }, + select: ['reputation'], + }), ]); return { numPosts: postStats?.numPosts ?? 0, @@ -1555,6 +1560,7 @@ export const resolvers: IResolvers = traceResolvers< numCommentUpvotes: commentStats?.numCommentUpvotes ?? 0, numFollowing, numFollowers, + reputation: user?.reputation, }; }, user: async ( From f69d987737b1035c35a1a5ada70242c6c8d57cf2 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Mon, 13 Oct 2025 20:47:12 +0200 Subject: [PATCH 04/12] remove duplicate prop --- src/routes/boot.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes/boot.ts b/src/routes/boot.ts index 20208ddf6c..e064668df0 100644 --- a/src/routes/boot.ts +++ b/src/routes/boot.ts @@ -477,7 +477,6 @@ const getUser = ( 'flags', 'coresRole', 'locationId', - 'readme', ], }); From 9803782cd7db9b057f33a8600a433eb56e0f6986 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Tue, 14 Oct 2025 19:18:14 +0200 Subject: [PATCH 05/12] join column and idx --- src/entity/user/User.ts | 5 +++++ src/migration/1760354446019-UserLocationId.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/src/entity/user/User.ts b/src/entity/user/User.ts index 5bcf3bb884..65a6103b01 100644 --- a/src/entity/user/User.ts +++ b/src/entity/user/User.ts @@ -345,5 +345,10 @@ export class User { locationId: string | null; @ManyToOne('DatasetLocation', { lazy: true }) + @JoinColumn({ + name: 'locationId', + foreignKeyConstraintName: 'FK_user_locationId', + }) + @Index('IDX_user_locationId') location: Promise; } diff --git a/src/migration/1760354446019-UserLocationId.ts b/src/migration/1760354446019-UserLocationId.ts index dd18a9ae4c..f6196984a7 100644 --- a/src/migration/1760354446019-UserLocationId.ts +++ b/src/migration/1760354446019-UserLocationId.ts @@ -8,9 +8,13 @@ export class UserLocationId1760354446019 implements MigrationInterface { 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"`, ); From 05f169384bb2aa7591e75eb55d31e2f999619aa0 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Tue, 14 Oct 2025 20:09:26 +0200 Subject: [PATCH 06/12] remove rep, rename type --- src/routes/boot.ts | 4 ++-- src/schema/users.ts | 10 ++-------- src/types.ts | 2 +- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/routes/boot.ts b/src/routes/boot.ts index e064668df0..143672eb02 100644 --- a/src/routes/boot.ts +++ b/src/routes/boot.ts @@ -73,7 +73,7 @@ import { SEMATTRS_DAILY_STAFF, } from '../telemetry'; import { getUnreadNotificationsCount } from '../notifications/common'; -import { maxFeedsPerUser, type CoresRole, type Location } from '../types'; +import { maxFeedsPerUser, type CoresRole, type TLocation } from '../types'; import { queryReadReplica } from '../common/queryReadReplica'; import { queryDataSource } from '../common/queryDataSource'; import { isPlusMember } from '../paddle'; @@ -147,7 +147,7 @@ export type LoggedInBoot = BaseBoot & { canSubmitArticle: boolean; balance: GetBalanceResult; coresRole: CoresRole; - location?: Location | null; + location?: TLocation | null; }; accessToken?: AccessToken; marketingCta: MarketingCta | null; diff --git a/src/schema/users.ts b/src/schema/users.ts index cf3f2cd9f2..36f7f2dbd0 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -250,7 +250,7 @@ type FollowStats = { numFollowing: number; numFollowers: number }; export type GQLUserStats = Omit & CommentStats & - FollowStats & { reputation?: number }; + FollowStats; export interface GQLReadingRank { rankThisWeek?: number; @@ -669,7 +669,6 @@ export const typeDefs = /* GraphQL */ ` numCommentUpvotes: Int numFollowers: Int numFollowing: Int - reputation: Int } type ReadingRank { @@ -1513,7 +1512,7 @@ export const resolvers: IResolvers = traceResolvers< { id }: { id: string }, ctx: Context, ): Promise => { - const [postStats, commentStats, numFollowing, numFollowers, user] = + const [postStats, commentStats, numFollowing, numFollowers] = await Promise.all([ getAuthorPostStats(ctx.con, id), ctx.con @@ -1547,10 +1546,6 @@ export const resolvers: IResolvers = traceResolvers< }) .andWhere('cp."feedId" = cp."userId"') .getCount(), - ctx.con.getRepository(User).findOne({ - where: { id }, - select: ['reputation'], - }), ]); return { numPosts: postStats?.numPosts ?? 0, @@ -1560,7 +1555,6 @@ export const resolvers: IResolvers = traceResolvers< numCommentUpvotes: commentStats?.numCommentUpvotes ?? 0, numFollowing, numFollowers, - reputation: user?.reputation, }; }, user: async ( diff --git a/src/types.ts b/src/types.ts index 6516a5e67d..0c1c82e190 100644 --- a/src/types.ts +++ b/src/types.ts @@ -305,7 +305,7 @@ export type ServiceClient = { garmr: GarmrService; }; -export type Location = { +export type TLocation = { id: string; country: string; subdivision: string | null; From 4115b53750d6135de12d57cc2448660d628e450e Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 15 Oct 2025 12:22:55 +0200 Subject: [PATCH 07/12] increase image upload amount to 2 --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, { From 253424f7c48ce586c25f19ecf484cee3a325e548 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 5 Nov 2025 12:31:27 +0100 Subject: [PATCH 08/12] fix merge issue --- src/schema/users.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/schema/users.ts b/src/schema/users.ts index 36f7f2dbd0..3fb7f9c809 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -197,11 +197,14 @@ export interface GQLUpdateUserInput { flags: UserFlagsPublic; notificationFlags?: UserNotificationFlags; locationId?: string; + cover?: string; + readme?: string; } interface GQLUserParameters { data: GQLUpdateUserInput; upload: Promise; + coverUpload?: Promise; } export interface GQLUser { @@ -2213,7 +2216,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); @@ -2272,7 +2275,13 @@ export const resolvers: IResolvers = traceResolvers< 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 From 5bb3245833df57f351b01c30fb165abf08796ca3 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 5 Nov 2025 13:08:19 +0100 Subject: [PATCH 09/12] remove readme from boot --- src/routes/boot.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/boot.ts b/src/routes/boot.ts index 143672eb02..6b96f1a5f4 100644 --- a/src/routes/boot.ts +++ b/src/routes/boot.ts @@ -937,6 +937,7 @@ const getFunnelLoggedInData = async ( 'flags', 'locationId', 'readmeHtml', + 'readme', ]), providers: [null], permalink: `${process.env.COMMENTS_PREFIX}/${user.username || user.id}`, From 3994a64316a50aba8e1114a5b136b77d018d6f1b Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 5 Nov 2025 13:36:43 +0100 Subject: [PATCH 10/12] re-add missing cover upload prop --- src/schema/users.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/schema/users.ts b/src/schema/users.ts index 3fb7f9c809..502de0f9b8 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -1068,7 +1068,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 From ff79aee33e13ea19b5acf5ee3fd7ac082a1e5aa8 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 5 Nov 2025 16:21:41 +0100 Subject: [PATCH 11/12] update schema --- src/schema/users.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/schema/users.ts b/src/schema/users.ts index 502de0f9b8..3b84310dc8 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -553,6 +553,10 @@ export const typeDefs = /* GraphQL */ ` """ image: String """ + The cover image of the user + """ + cover: String + """ Username (handle) of the user """ username: String @@ -656,6 +660,10 @@ export const typeDefs = /* GraphQL */ ` id of the location selected by the user """ locationId: String + """ + The user's readme + """ + readme: String } type TagsReadingStatus { From 9ef02b599c5df70e6ffa3e4cac8e5c2f26854091 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 12 Nov 2025 15:42:46 +0100 Subject: [PATCH 12/12] add location to user --- src/schema/users.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/schema/users.ts b/src/schema/users.ts index 3b84310dc8..3208b08e16 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -335,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 """ @@ -496,6 +515,10 @@ export const typeDefs = /* GraphQL */ ` Role for Cores access """ coresRole: Int + """ + Where the user is located + """ + location: DatasetLocation } """