Skip to content

Commit cd9222f

Browse files
authored
feat: add support for building social links from legacy fields and reverse dual-write (#3407)
1 parent ad280b0 commit cd9222f

4 files changed

Lines changed: 195 additions & 4 deletions

File tree

.claude/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"Bash(docker-compose:*)",
5353
"WebFetch(domain:*.daily.dev)",
5454
"mcp__github__*",
55+
"mcp__plugin_linear_linear__*",
5556
"Read",
5657
"Glob",
5758
"Grep",

__tests__/updateUserInfo.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ describe('mutation updateUserInfo', () => {
8484
twitter
8585
github
8686
hashnode
87+
linkedin
8788
createdAt
8889
infoConfirmed
8990
timezone
@@ -97,6 +98,10 @@ describe('mutation updateUserInfo', () => {
9798
city
9899
}
99100
hideExperience
101+
socialLinks {
102+
platform
103+
url
104+
}
100105
}
101106
}
102107
`;
@@ -712,4 +717,47 @@ describe('mutation updateUserInfo', () => {
712717
const updatedUser = await repo.findOneBy({ id: loggedUser });
713718
expect(updatedUser?.hideExperience).toBe(false);
714719
});
720+
721+
describe('socialLinks', () => {
722+
it('should reverse dual-write: update socialLinks when legacy fields are provided', async () => {
723+
loggedUser = '1';
724+
const repo = con.getRepository(User);
725+
const user = await repo.findOneBy({ id: loggedUser });
726+
727+
// Use legacy format (individual fields like the client currently sends)
728+
const res = await client.mutate(MUTATION, {
729+
variables: {
730+
data: {
731+
name: user?.name,
732+
username: 'testuser',
733+
github: 'idoshamun',
734+
linkedin: 'ido-shamun',
735+
twitter: 'idoshamun',
736+
portfolio: 'https://shamun.dev',
737+
youtube: null,
738+
stackoverflow: null,
739+
},
740+
},
741+
});
742+
743+
expect(res.errors).toBeFalsy();
744+
745+
// Verify legacy columns are updated
746+
expect(res.data.updateUserInfo.github).toBe('idoshamun');
747+
expect(res.data.updateUserInfo.linkedin).toBe('ido-shamun');
748+
expect(res.data.updateUserInfo.twitter).toBe('idoshamun');
749+
750+
// Verify socialLinks JSONB is also populated with full URLs (order may vary)
751+
const updated = await repo.findOneBy({ id: loggedUser });
752+
expect(updated?.socialLinks).toHaveLength(4);
753+
expect(updated?.socialLinks).toEqual(
754+
expect.arrayContaining([
755+
{ platform: 'github', url: 'https://github.com/idoshamun' },
756+
{ platform: 'linkedin', url: 'https://linkedin.com/in/ido-shamun' },
757+
{ platform: 'twitter', url: 'https://x.com/idoshamun' },
758+
{ platform: 'portfolio', url: 'https://shamun.dev' },
759+
]),
760+
);
761+
});
762+
});
715763
});

__tests__/users.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4304,6 +4304,31 @@ describe('mutation updateUserProfile', () => {
43044304
const updated = await con.getRepository(User).findOneBy({ id: loggedUser });
43054305
expect(updated?.socialLinks).toEqual([]);
43064306
});
4307+
4308+
it('should reverse dual-write: update socialLinks when legacy fields are provided', async () => {
4309+
loggedUser = '1';
4310+
4311+
// Use legacy format (individual fields)
4312+
const res = await client.mutate(MUTATION, {
4313+
variables: {
4314+
data: {
4315+
github: 'myhandle',
4316+
linkedin: 'myprofile',
4317+
},
4318+
},
4319+
});
4320+
4321+
expect(res.errors).toBeFalsy();
4322+
4323+
// Verify socialLinks JSONB is populated with full URLs
4324+
const updated = await con.getRepository(User).findOneBy({ id: loggedUser });
4325+
expect(updated?.socialLinks).toEqual(
4326+
expect.arrayContaining([
4327+
{ platform: 'github', url: 'https://github.com/myhandle' },
4328+
{ platform: 'linkedin', url: 'https://linkedin.com/in/myprofile' },
4329+
]),
4330+
);
4331+
});
43074332
});
43084333

43094334
describe('mutation deleteUser', () => {

src/schema/users.ts

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1716,6 +1716,107 @@ export const clearImagePreset = async ({
17161716
}
17171717
};
17181718

1719+
/**
1720+
* Platform URL templates for building URLs from handles
1721+
*/
1722+
const PLATFORM_URL_TEMPLATES: Record<string, (handle: string) => string> = {
1723+
twitter: (handle) => `https://x.com/${handle}`,
1724+
github: (handle) => `https://github.com/${handle}`,
1725+
linkedin: (handle) => `https://linkedin.com/in/${handle}`,
1726+
threads: (handle) => `https://threads.net/@${handle}`,
1727+
roadmap: (handle) => `https://roadmap.sh/u/${handle}`,
1728+
codepen: (handle) => `https://codepen.io/${handle}`,
1729+
reddit: (handle) => `https://reddit.com/u/${handle}`,
1730+
stackoverflow: (handle) => `https://stackoverflow.com/users/${handle}`,
1731+
youtube: (handle) => `https://youtube.com/@${handle}`,
1732+
bluesky: (handle) => `https://bsky.app/profile/${handle}`,
1733+
hashnode: (handle) => `https://hashnode.com/@${handle}`,
1734+
// These platforms store full URLs, not handles
1735+
mastodon: (url) => url,
1736+
portfolio: (url) => url,
1737+
};
1738+
1739+
/**
1740+
* Build URL from handle for a given platform
1741+
*/
1742+
function buildUrlFromHandle(
1743+
handle: string | null | undefined,
1744+
platform: string,
1745+
): string | null {
1746+
if (!handle) return null;
1747+
const template = PLATFORM_URL_TEMPLATES[platform];
1748+
if (!template) return null;
1749+
return template(handle);
1750+
}
1751+
1752+
/**
1753+
* Build socialLinks array from legacy column values
1754+
* Used for reverse dual-write when legacy fields are updated
1755+
*/
1756+
function buildSocialLinksFromLegacyFields(
1757+
data: Partial<GQLUpdateUserInput>,
1758+
existingUser: User,
1759+
): UserSocialLink[] {
1760+
const legacyPlatforms = [
1761+
'twitter',
1762+
'github',
1763+
'linkedin',
1764+
'threads',
1765+
'roadmap',
1766+
'codepen',
1767+
'reddit',
1768+
'stackoverflow',
1769+
'youtube',
1770+
'bluesky',
1771+
'mastodon',
1772+
'hashnode',
1773+
'portfolio',
1774+
] as const;
1775+
1776+
const socialLinks: UserSocialLink[] = [];
1777+
1778+
for (const platform of legacyPlatforms) {
1779+
// Use the new value from data if provided, otherwise use existing user value
1780+
const handle =
1781+
platform in data
1782+
? (data[platform as keyof GQLUpdateUserInput] as string | null)
1783+
: (existingUser[platform] as string | null);
1784+
1785+
if (handle) {
1786+
const url = buildUrlFromHandle(handle, platform);
1787+
if (url) {
1788+
socialLinks.push({ platform, url });
1789+
}
1790+
}
1791+
}
1792+
1793+
return socialLinks;
1794+
}
1795+
1796+
/**
1797+
* Check if any legacy social fields are being updated
1798+
*/
1799+
function hasLegacySocialFieldsUpdate(
1800+
data: Partial<GQLUpdateUserInput>,
1801+
): boolean {
1802+
const legacyPlatforms = [
1803+
'twitter',
1804+
'github',
1805+
'linkedin',
1806+
'threads',
1807+
'roadmap',
1808+
'codepen',
1809+
'reddit',
1810+
'stackoverflow',
1811+
'youtube',
1812+
'bluesky',
1813+
'mastodon',
1814+
'hashnode',
1815+
'portfolio',
1816+
];
1817+
return legacyPlatforms.some((platform) => platform in data);
1818+
}
1819+
17191820
/**
17201821
* Extract handle/value from URL for legacy column storage
17211822
*/
@@ -1761,8 +1862,8 @@ function extractHandleFromUrl(url: string, platform: string): string | null {
17611862
// Full URL is stored for mastodon
17621863
return url;
17631864
case 'hashnode':
1764-
// Full URL is stored for hashnode
1765-
return url;
1865+
// https://hashnode.com/@username
1866+
return pathname.replace(/^\/@?/, '') || null;
17661867
case 'portfolio':
17671868
// Full URL is stored for portfolio
17681869
return url;
@@ -2583,13 +2684,21 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
25832684
: data.image || user.image;
25842685

25852686
try {
2586-
// Process socialLinks for dual-write if provided
2687+
// Process socialLinks for dual-write
25872688
let socialLinksData: {
25882689
socialLinks?: UserSocialLink[];
25892690
legacyColumns?: Record<string, string | null>;
25902691
} = {};
2692+
25912693
if (data.socialLinks) {
2694+
// New format: socialLinks array provided -> write to both socialLinks and legacy columns
25922695
socialLinksData = processSocialLinksForDualWrite(data.socialLinks);
2696+
} else if (hasLegacySocialFieldsUpdate(data)) {
2697+
// Legacy format: individual fields provided -> build socialLinks from merged values
2698+
socialLinksData.socialLinks = buildSocialLinksFromLegacyFields(
2699+
data,
2700+
user,
2701+
);
25932702
}
25942703

25952704
const updatedUser = {
@@ -2733,13 +2842,21 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
27332842
try {
27342843
delete data.externalLocationId;
27352844

2736-
// Process socialLinks for dual-write if provided
2845+
// Process socialLinks for dual-write
27372846
let socialLinksData: {
27382847
socialLinks?: UserSocialLink[];
27392848
legacyColumns?: Record<string, string | null>;
27402849
} = {};
2850+
27412851
if (data.socialLinks) {
2852+
// New format: socialLinks array provided -> write to both socialLinks and legacy columns
27422853
socialLinksData = processSocialLinksForDualWrite(data.socialLinks);
2854+
} else if (hasLegacySocialFieldsUpdate(data)) {
2855+
// Legacy format: individual fields provided -> build socialLinks from merged values
2856+
socialLinksData.socialLinks = buildSocialLinksFromLegacyFields(
2857+
data,
2858+
user,
2859+
);
27432860
}
27442861

27452862
const updatedUser = {

0 commit comments

Comments
 (0)