diff --git a/__tests__/boot.ts b/__tests__/boot.ts index 78c8615309..60e2850875 100644 --- a/__tests__/boot.ts +++ b/__tests__/boot.ts @@ -137,6 +137,9 @@ const LOGGED_IN_BODY = { company: null, experienceLevel: null, isTeamMember: false, + twitter: null, + github: 'idogithub', + hashnode: null, bluesky: null, roadmap: null, threads: null, diff --git a/__tests__/fixture/user.ts b/__tests__/fixture/user.ts index 6a1068d084..5a676e0464 100644 --- a/__tests__/fixture/user.ts +++ b/__tests__/fixture/user.ts @@ -9,52 +9,46 @@ export const usersFixture: DeepPartial[] = [ { id: '1', bio: null, - github: 'idogithub', - hashnode: null, name: 'Ido', image: 'https://daily.dev/ido.jpg', email: 'ido@daily.dev', createdAt: new Date(userCreatedDate), - twitter: null, username: 'idoshamun', infoConfirmed: true, notificationFlags: DEFAULT_NOTIFICATION_SETTINGS, + socialLinks: [ + { + platform: 'github', + url: 'https://github.com/idogithub', + }, + ], }, { id: '2', bio: null, - github: null, - hashnode: null, name: 'Tsahi', email: 'tsahi@daily.dev', image: 'https://daily.dev/tsahi.jpg', createdAt: new Date(userCreatedDate), - twitter: null, username: 'tsahidaily', infoConfirmed: true, }, { id: '3', bio: null, - github: null, - hashnode: null, name: 'Nimrod', email: 'nimrod@daily.dev', image: 'https://daily.dev/nimrod.jpg', createdAt: new Date(userCreatedDate), - twitter: null, username: 'nimroddaily', infoConfirmed: true, }, { id: '4', bio: null, - github: null, - hashnode: null, name: 'Lee', image: 'https://daily.dev/lee.jpg', createdAt: new Date(userCreatedDate), - twitter: null, username: 'lee', infoConfirmed: true, }, @@ -76,12 +70,9 @@ export const badUsersFixture: DeepPartial[] = [ { id: 'vordr', bio: null, - github: null, - hashnode: null, name: 'Vordr was here', image: 'https://daily.dev/lee.jpg', createdAt: new Date(userCreatedDate), - twitter: null, username: 'vordr', infoConfirmed: true, flags: { @@ -92,12 +83,9 @@ export const badUsersFixture: DeepPartial[] = [ { id: 'low-score', bio: null, - github: null, - hashnode: null, name: 'Low Score', image: 'https://daily.dev/lee.jpg', createdAt: new Date(userCreatedDate), - twitter: null, username: 'low-score', infoConfirmed: true, flags: { @@ -108,12 +96,9 @@ export const badUsersFixture: DeepPartial[] = [ { id: 'low-reputation', bio: null, - github: null, - hashnode: null, name: 'Low Reputation', image: 'https://daily.dev/lee.jpg', createdAt: new Date(userCreatedDate), - twitter: null, username: 'low-reputation', infoConfirmed: true, reputation: 0, diff --git a/__tests__/private.ts b/__tests__/private.ts index 8a1f160348..309f623e77 100644 --- a/__tests__/private.ts +++ b/__tests__/private.ts @@ -355,7 +355,7 @@ describe('POST /p/newUser', () => { image: usersFixture[0].image, username: usersFixture[0].username, email: usersFixture[0].email, - github: usersFixture[0].github, + github: 'testgithub', experienceLevel: 'LESS_THAN_1_YEAR', }) .expect(200); @@ -365,13 +365,13 @@ describe('POST /p/newUser', () => { const users = await con.getRepository(User).find({ order: { id: 'ASC' } }); expect(users.length).toEqual(3); expect(users[0].id).toEqual(usersFixture[0].id); - expect(users[0].github).toEqual(usersFixture[0].github); + expect(users[0].github).toEqual('testgithub'); }); it('should ignore GitHub handle if it already exists', async () => { await con .getRepository(User) - .save({ ...usersFixture[1], github: usersFixture[0].github }); + .save({ ...usersFixture[1], github: 'testgithub' }); const { body } = await request(app.server) .post('/p/newUser') @@ -383,7 +383,7 @@ describe('POST /p/newUser', () => { image: usersFixture[0].image, username: usersFixture[0].username, email: usersFixture[0].email, - github: usersFixture[0].github, + github: 'testgithub', experienceLevel: 'LESS_THAN_1_YEAR', }) .expect(200); @@ -407,7 +407,7 @@ describe('POST /p/newUser', () => { image: usersFixture[0].image, username: usersFixture[0].username, email: usersFixture[0].email, - twitter: usersFixture[0].twitter, + twitter: 'testtwitter', experienceLevel: 'LESS_THAN_1_YEAR', }) .expect(200); @@ -416,13 +416,13 @@ describe('POST /p/newUser', () => { const users = await con.getRepository(User).find({ order: { id: 'ASC' } }); expect(users[0].id).toEqual(usersFixture[0].id); - expect(users[0].twitter).toEqual(usersFixture[0].twitter); + expect(users[0].twitter).toEqual('testtwitter'); }); it('should ignore Twitter handle if it already exists', async () => { await con .getRepository(User) - .save({ ...usersFixture[1], twitter: usersFixture[0].twitter }); + .save({ ...usersFixture[1], twitter: 'testtwitter' }); const { body } = await request(app.server) .post('/p/newUser') @@ -434,7 +434,7 @@ describe('POST /p/newUser', () => { image: usersFixture[0].image, username: usersFixture[0].username, email: usersFixture[0].email, - twitter: usersFixture[0].twitter, + twitter: 'testtwitter', experienceLevel: 'LESS_THAN_1_YEAR', }) .expect(200); diff --git a/__tests__/users.ts b/__tests__/users.ts index 5315e54b89..7bb3286c4c 100644 --- a/__tests__/users.ts +++ b/__tests__/users.ts @@ -245,17 +245,22 @@ beforeEach(async () => { image: 'https://daily.dev/lee.jpg', timezone: userTimezone, username: 'lee', - twitter: 'lee', - github: 'lee', - hashnode: 'lee', - roadmap: 'lee', - threads: 'lee', - codepen: 'lee', - reddit: 'lee', - stackoverflow: '999999/lee', - youtube: 'lee', - linkedin: 'lee', - mastodon: 'https://mastodon.social/@lee', + socialLinks: [ + { platform: 'twitter', url: 'https://twitter.com/lee' }, + { platform: 'github', url: 'https://github.com/lee' }, + { platform: 'hashnode', url: 'https://hashnode.com/@lee' }, + { platform: 'roadmap', url: 'https://roadmap.sh/u/lee' }, + { platform: 'threads', url: 'https://threads.net/@lee' }, + { platform: 'codepen', url: 'https://codepen.io/lee' }, + { platform: 'reddit', url: 'https://reddit.com/u/lee' }, + { + platform: 'stackoverflow', + url: 'https://stackoverflow.com/users/999999/lee', + }, + { platform: 'youtube', url: 'https://youtube.com/@lee' }, + { platform: 'linkedin', url: 'https://linkedin.com/in/lee' }, + { platform: 'mastodon', url: 'https://mastodon.social/@lee' }, + ], }, ]); await saveFixtures(con, Source, sourcesFixture); @@ -1897,6 +1902,40 @@ describe('query user socialLinks', () => { { platform: 'twitter', url: 'https://twitter.com/testhandle' }, ]); }); + + it('should resolve legacy social fields from socialLinks for backwards compatibility', async () => { + await con.getRepository(User).update( + { id: '1' }, + { + socialLinks: [ + { platform: 'github', url: 'https://github.com/testuser' }, + { platform: 'twitter', url: 'https://twitter.com/testhandle' }, + { platform: 'linkedin', url: 'https://linkedin.com/in/testprofile' }, + { platform: 'mastodon', url: 'https://mastodon.social/@testuser' }, + { platform: 'portfolio', url: 'https://example.com' }, + ], + }, + ); + + const LEGACY_QUERY = `query User($id: ID!) { + user(id: $id) { + id + github + twitter + linkedin + mastodon + portfolio + } + }`; + + const res = await client.query(LEGACY_QUERY, { variables: { id: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.user.github).toBe('testuser'); + expect(res.data.user.twitter).toBe('testhandle'); + expect(res.data.user.linkedin).toBe('testprofile'); + expect(res.data.user.mastodon).toBe('https://mastodon.social/@testuser'); + expect(res.data.user.portfolio).toBe('https://example.com'); + }); }); describe('query team members', () => { @@ -3489,36 +3528,6 @@ describe('mutation updateUserProfile', () => { 'UNAUTHENTICATED', )); - it('should not allow duplicated github', async () => { - loggedUser = '1'; - - await testMutationErrorCode( - client, - { mutation: MUTATION, variables: { data: { github: 'lee' } } }, - 'GRAPHQL_VALIDATION_FAILED', - ); - }); - - it('should not allow duplicated twitter', async () => { - loggedUser = '1'; - - await testMutationErrorCode( - client, - { mutation: MUTATION, variables: { data: { twitter: 'lee' } } }, - 'GRAPHQL_VALIDATION_FAILED', - ); - }); - - it('should not allow duplicated hashnode', async () => { - loggedUser = '1'; - - await testMutationErrorCode( - client, - { mutation: MUTATION, variables: { data: { hashnode: 'lee' } } }, - 'GRAPHQL_VALIDATION_FAILED', - ); - }); - it('should not allow duplicated username', async () => { loggedUser = '1'; @@ -3529,82 +3538,6 @@ describe('mutation updateUserProfile', () => { ); }); - it('should not allow duplicated roadmap', async () => { - loggedUser = '1'; - - await testMutationErrorCode( - client, - { mutation: MUTATION, variables: { data: { roadmap: 'lee' } } }, - 'GRAPHQL_VALIDATION_FAILED', - ); - }); - - it('should not allow duplicated threads', async () => { - loggedUser = '1'; - - await testMutationErrorCode( - client, - { mutation: MUTATION, variables: { data: { threads: 'lee' } } }, - 'GRAPHQL_VALIDATION_FAILED', - ); - }); - - it('should not allow duplicated codepen', async () => { - loggedUser = '1'; - - await testMutationErrorCode( - client, - { mutation: MUTATION, variables: { data: { codepen: 'lee' } } }, - 'GRAPHQL_VALIDATION_FAILED', - ); - }); - - it('should not allow duplicated reddit', async () => { - loggedUser = '1'; - - await testMutationErrorCode( - client, - { mutation: MUTATION, variables: { data: { reddit: 'lee' } } }, - 'GRAPHQL_VALIDATION_FAILED', - ); - }); - - it('should not allow duplicated stackoverflow', async () => { - loggedUser = '1'; - - await testMutationErrorCode( - client, - { - mutation: MUTATION, - variables: { data: { stackoverflow: '999999/lee' } }, - }, - 'GRAPHQL_VALIDATION_FAILED', - ); - }); - - it('should not allow duplicated linkedin', async () => { - loggedUser = '1'; - - await testMutationErrorCode( - client, - { mutation: MUTATION, variables: { data: { linkedin: 'lee' } } }, - 'GRAPHQL_VALIDATION_FAILED', - ); - }); - - it('should not allow duplicated mastodon', async () => { - loggedUser = '1'; - - await testMutationErrorCode( - client, - { - mutation: MUTATION, - variables: { data: { mastodon: 'https://mastodon.social/@lee' } }, - }, - 'GRAPHQL_VALIDATION_FAILED', - ); - }); - it('should not allow empty username', async () => { loggedUser = '1'; diff --git a/__tests__/whoami.ts b/__tests__/whoami.ts index 670e8e29fa..7afbf05cba 100644 --- a/__tests__/whoami.ts +++ b/__tests__/whoami.ts @@ -64,9 +64,12 @@ describe('query whoami', () => { const res = await client.query(QUERY); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { email, notificationFlags, ...user } = usersFixture[0]; + const { email, notificationFlags, socialLinks, ...user } = usersFixture[0]; expect(res.data.whoami).toEqual({ ...user, + twitter: null, + github: 'idogithub', + hashnode: null, timezone: DEFAULT_TIMEZONE, createdAt: userCreatedDate, }); @@ -81,7 +84,7 @@ describe('dedicated api routes', () => { request(app.server).get('/whoami'), ).expect(200); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { notificationFlags, ...user } = usersFixture[0]; + const { notificationFlags, socialLinks, ...user } = usersFixture[0]; expect(res.body).toEqual({ ...user, company: null, @@ -90,6 +93,9 @@ describe('dedicated api routes', () => { timezone: DEFAULT_TIMEZONE, createdAt: userCreatedDate, reputation: 10, + twitter: null, + github: 'idogithub', + hashnode: null, roadmap: null, threads: null, codepen: null, diff --git a/src/common/schema/socials.ts b/src/common/schema/socials.ts index a31b7ee759..fea734dab1 100644 --- a/src/common/schema/socials.ts +++ b/src/common/schema/socials.ts @@ -157,3 +157,65 @@ export const socialLinksInputSchema = z export type SocialLinkInput = z.input; export type SocialLink = z.output[number]; + +/** + * Extract handle or identifier from a social link URL + * Returns null if URL is invalid or doesn't match expected format + */ +export function extractHandleFromUrl( + url: string, + platform: string, +): string | null { + if (!url) return null; + + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname.replace(/\/$/, ''); // Remove trailing slash + + switch (platform) { + case 'twitter': + // https://x.com/username or https://twitter.com/username + return pathname.replace(/^\//, '').replace('@', '') || null; + case 'github': + // https://github.com/username + return pathname.replace(/^\//, '') || null; + case 'linkedin': + // https://linkedin.com/in/username + return pathname.replace(/^\/in\//, '') || null; + case 'threads': + // https://threads.net/@username + return pathname.replace(/^\/@?/, '') || null; + case 'roadmap': + // https://roadmap.sh/u/username + return pathname.replace(/^\/u\//, '') || null; + case 'codepen': + // https://codepen.io/username + return pathname.replace(/^\//, '') || null; + case 'reddit': + // https://reddit.com/u/username or /user/username + return pathname.replace(/^\/(u|user)\//, '') || null; + case 'stackoverflow': + // https://stackoverflow.com/users/123/username + return pathname.replace(/^\/users\//, '') || null; + case 'youtube': + // https://youtube.com/@username + return pathname.replace(/^\/@?/, '') || null; + case 'bluesky': + // https://bsky.app/profile/username.bsky.social + return pathname.replace(/^\/profile\//, '') || null; + case 'mastodon': + // Full URL is stored for mastodon + return url; + case 'hashnode': + // https://hashnode.com/@username + return pathname.replace(/^\/@?/, '') || null; + case 'portfolio': + // Full URL is stored for portfolio + return url; + default: + return null; + } + } catch { + return null; + } +} diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index 9848ba7777..b47e506161 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -69,6 +69,7 @@ import { import { OpportunityUserRecruiter } from '../entity/opportunities/user'; import { OpportunityUserType } from '../entity/opportunities/types'; import { OrganizationLinkType } from '../common/schema/organizations'; +import { extractHandleFromUrl } from '../common/schema/socials'; import type { GCSBlob } from '../common/schema/userCandidate'; import { QuestionType } from '../entity/questions/types'; import { snotraClient } from '../integrations/snotra'; @@ -306,6 +307,98 @@ const obj = new GraphORM({ socialLinks: { jsonType: true, }, + // Legacy social fields - resolved from socialLinks for backwards compatibility + twitter: { + alias: { field: 'socialLinks', type: 'jsonb' }, + transform: (socialLinks: Array<{ platform: string; url: string }>) => { + const link = socialLinks?.find((l) => l.platform === 'twitter'); + return link ? extractHandleFromUrl(link.url, 'twitter') : null; + }, + }, + github: { + alias: { field: 'socialLinks', type: 'jsonb' }, + transform: (socialLinks: Array<{ platform: string; url: string }>) => { + const link = socialLinks?.find((l) => l.platform === 'github'); + return link ? extractHandleFromUrl(link.url, 'github') : null; + }, + }, + linkedin: { + alias: { field: 'socialLinks', type: 'jsonb' }, + transform: (socialLinks: Array<{ platform: string; url: string }>) => { + const link = socialLinks?.find((l) => l.platform === 'linkedin'); + return link ? extractHandleFromUrl(link.url, 'linkedin') : null; + }, + }, + threads: { + alias: { field: 'socialLinks', type: 'jsonb' }, + transform: (socialLinks: Array<{ platform: string; url: string }>) => { + const link = socialLinks?.find((l) => l.platform === 'threads'); + return link ? extractHandleFromUrl(link.url, 'threads') : null; + }, + }, + roadmap: { + alias: { field: 'socialLinks', type: 'jsonb' }, + transform: (socialLinks: Array<{ platform: string; url: string }>) => { + const link = socialLinks?.find((l) => l.platform === 'roadmap'); + return link ? extractHandleFromUrl(link.url, 'roadmap') : null; + }, + }, + codepen: { + alias: { field: 'socialLinks', type: 'jsonb' }, + transform: (socialLinks: Array<{ platform: string; url: string }>) => { + const link = socialLinks?.find((l) => l.platform === 'codepen'); + return link ? extractHandleFromUrl(link.url, 'codepen') : null; + }, + }, + reddit: { + alias: { field: 'socialLinks', type: 'jsonb' }, + transform: (socialLinks: Array<{ platform: string; url: string }>) => { + const link = socialLinks?.find((l) => l.platform === 'reddit'); + return link ? extractHandleFromUrl(link.url, 'reddit') : null; + }, + }, + stackoverflow: { + alias: { field: 'socialLinks', type: 'jsonb' }, + transform: (socialLinks: Array<{ platform: string; url: string }>) => { + const link = socialLinks?.find((l) => l.platform === 'stackoverflow'); + return link ? extractHandleFromUrl(link.url, 'stackoverflow') : null; + }, + }, + youtube: { + alias: { field: 'socialLinks', type: 'jsonb' }, + transform: (socialLinks: Array<{ platform: string; url: string }>) => { + const link = socialLinks?.find((l) => l.platform === 'youtube'); + return link ? extractHandleFromUrl(link.url, 'youtube') : null; + }, + }, + bluesky: { + alias: { field: 'socialLinks', type: 'jsonb' }, + transform: (socialLinks: Array<{ platform: string; url: string }>) => { + const link = socialLinks?.find((l) => l.platform === 'bluesky'); + return link ? extractHandleFromUrl(link.url, 'bluesky') : null; + }, + }, + mastodon: { + alias: { field: 'socialLinks', type: 'jsonb' }, + transform: (socialLinks: Array<{ platform: string; url: string }>) => { + const link = socialLinks?.find((l) => l.platform === 'mastodon'); + return link ? extractHandleFromUrl(link.url, 'mastodon') : null; + }, + }, + hashnode: { + alias: { field: 'socialLinks', type: 'jsonb' }, + transform: (socialLinks: Array<{ platform: string; url: string }>) => { + const link = socialLinks?.find((l) => l.platform === 'hashnode'); + return link ? extractHandleFromUrl(link.url, 'hashnode') : null; + }, + }, + portfolio: { + alias: { field: 'socialLinks', type: 'jsonb' }, + transform: (socialLinks: Array<{ platform: string; url: string }>) => { + const link = socialLinks?.find((l) => l.platform === 'portfolio'); + return link ? extractHandleFromUrl(link.url, 'portfolio') : null; + }, + }, }, }, UserCompany: { diff --git a/src/routes/boot.ts b/src/routes/boot.ts index fd38cecef3..e0977c0da5 100644 --- a/src/routes/boot.ts +++ b/src/routes/boot.ts @@ -10,6 +10,7 @@ import { clearAuthentication, dispatchWhoami } from '../kratos'; import { generateUUID } from '../ids'; import { generateSessionId, setTrackingId } from '../tracking'; import { GQLUser, getMarketingCta } from '../schema/users'; +import { extractHandleFromUrl } from '../common/schema/socials'; import { Alerts, ALERTS_DEFAULT, @@ -441,11 +442,11 @@ const getExperimentation = async ({ }; }; -const getUser = ( +const getUser = async ( con: DataSource | QueryRunner, userId: string, -): Promise => - con.manager.getRepository(User).findOne({ +): Promise => { + const user = await con.manager.getRepository(User).findOne({ where: { id: userId }, select: [ 'id', @@ -458,19 +459,7 @@ const getUser = ( 'infoConfirmed', 'reputation', 'bio', - 'twitter', - 'bluesky', - 'github', - 'portfolio', - 'hashnode', - 'roadmap', - 'threads', - 'codepen', - 'reddit', - 'stackoverflow', - 'youtube', - 'linkedin', - 'mastodon', + 'socialLinks', 'timezone', 'createdAt', 'cover', @@ -487,6 +476,37 @@ const getUser = ( ], }); + if (!user) return null; + + // Populate legacy social fields from socialLinks for backwards compatibility + const socialLinks = user.socialLinks || []; + + // Helper to get handle for a platform (returns undefined for entity compatibility) + const getHandle = (platform: string): string | undefined => { + const link = socialLinks.find((l) => l.platform === platform); + return link + ? (extractHandleFromUrl(link.url, platform) ?? undefined) + : undefined; + }; + + // Populate legacy fields for backwards compatibility + user.twitter = getHandle('twitter'); + user.github = getHandle('github'); + user.linkedin = getHandle('linkedin'); + user.threads = getHandle('threads'); + user.roadmap = getHandle('roadmap'); + user.codepen = getHandle('codepen'); + user.reddit = getHandle('reddit'); + user.stackoverflow = getHandle('stackoverflow'); + user.youtube = getHandle('youtube'); + user.bluesky = getHandle('bluesky'); + user.mastodon = getHandle('mastodon'); + user.hashnode = getHandle('hashnode'); + user.portfolio = getHandle('portfolio'); + + return user; +}; + const getBalanceBoot: typeof getBalance = async ({ userId }) => { try { const result = await getBalance({ userId }); @@ -703,6 +723,20 @@ const loggedInBoot = async ({ 'locationId', 'readmeHtml', ]), + // Legacy social fields with explicit null for JSON backwards compatibility + twitter: user.twitter ?? null, + github: user.github ?? null, + hashnode: user.hashnode ?? null, + linkedin: user.linkedin ?? null, + threads: user.threads ?? null, + roadmap: user.roadmap ?? null, + codepen: user.codepen ?? null, + reddit: user.reddit ?? null, + stackoverflow: user.stackoverflow ?? null, + youtube: user.youtube ?? null, + bluesky: user.bluesky ?? null, + mastodon: user.mastodon ?? null, + portfolio: user.portfolio ?? null, providers: [null], roles, permalink: `${process.env.COMMENTS_PREFIX}/${user.username || user.id}`, @@ -760,7 +794,7 @@ const loggedInBoot = async ({ feeds, geo, ...extra, - }; + } as LoggedInBoot; }); const getAnonymousFirstVisit = async (trackingId?: string) => { @@ -1028,12 +1062,26 @@ const getFunnelLoggedInData = async ( 'readmeHtml', 'readme', ]), + // Legacy social fields with explicit null for JSON backwards compatibility + twitter: user.twitter ?? null, + github: user.github ?? null, + hashnode: user.hashnode ?? null, + linkedin: user.linkedin ?? null, + threads: user.threads ?? null, + roadmap: user.roadmap ?? null, + codepen: user.codepen ?? null, + reddit: user.reddit ?? null, + stackoverflow: user.stackoverflow ?? null, + youtube: user.youtube ?? null, + bluesky: user.bluesky ?? null, + mastodon: user.mastodon ?? null, + portfolio: user.portfolio ?? null, providers: [null], permalink: `${process.env.COMMENTS_PREFIX}/${user.username || user.id}`, language: user.language || undefined, image: mapCloudinaryUrl(user.image), cover: mapCloudinaryUrl(user.cover), - }; + } as FunnelLoggedInUser; } } return null; diff --git a/src/schema/users.ts b/src/schema/users.ts index 29419766af..54f558f516 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -30,7 +30,10 @@ import { View, } from '../entity'; import { UserNotificationFlags, UserSocialLink } from '../entity/user/User'; -import { socialLinksInputSchema } from '../common/schema/socials'; +import { + extractHandleFromUrl, + socialLinksInputSchema, +} from '../common/schema/socials'; import { AuthenticationError, ForbiddenError, @@ -1817,64 +1820,6 @@ function hasLegacySocialFieldsUpdate( return legacyPlatforms.some((platform) => platform in data); } -/** - * Extract handle/value from URL for legacy column storage - */ -function extractHandleFromUrl(url: string, platform: string): string | null { - if (!url) return null; - - try { - const urlObj = new URL(url); - const pathname = urlObj.pathname.replace(/\/$/, ''); // Remove trailing slash - - switch (platform) { - case 'twitter': - // https://x.com/username or https://twitter.com/username - return pathname.replace(/^\//, '').replace('@', '') || null; - case 'github': - // https://github.com/username - return pathname.replace(/^\//, '') || null; - case 'linkedin': - // https://linkedin.com/in/username - return pathname.replace(/^\/in\//, '') || null; - case 'threads': - // https://threads.net/@username - return pathname.replace(/^\/@?/, '') || null; - case 'roadmap': - // https://roadmap.sh/u/username - return pathname.replace(/^\/u\//, '') || null; - case 'codepen': - // https://codepen.io/username - return pathname.replace(/^\//, '') || null; - case 'reddit': - // https://reddit.com/u/username or /user/username - return pathname.replace(/^\/(u|user)\//, '') || null; - case 'stackoverflow': - // https://stackoverflow.com/users/123/username - return pathname.replace(/^\/users\//, '') || null; - case 'youtube': - // https://youtube.com/@username - return pathname.replace(/^\/@?/, '') || null; - case 'bluesky': - // https://bsky.app/profile/username.bsky.social - return pathname.replace(/^\/profile\//, '') || null; - case 'mastodon': - // Full URL is stored for mastodon - return url; - case 'hashnode': - // https://hashnode.com/@username - return pathname.replace(/^\/@?/, '') || null; - case 'portfolio': - // Full URL is stored for portfolio - return url; - default: - return null; - } - } catch { - return null; - } -} - /** * Process socialLinks input and return both the JSONB array and legacy column values */