Skip to content

Commit ad280b0

Browse files
authored
feat: flexible social links - Phase 1 backend implementation (#3404)
1 parent 63c00cb commit ad280b0

7 files changed

Lines changed: 522 additions & 43 deletions

File tree

AGENTS.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,20 @@ This file provides guidance to coding agents when working with code in this repo
1919
- `pnpm run db:migrate:latest` - Apply latest migrations
2020
- `pnpm run db:migrate:reset` - Drop schema and rerun migrations
2121
- `pnpm run db:seed:import` - Import seed data for local development
22-
- `pnpm run db:migrate:make` - Generate new migration based on entity changes
23-
- `pnpm run db:migrate:create` - Create empty migration file
22+
- `pnpm run db:migrate:make src/migration/MigrationName` - Generate new migration based on entity changes
23+
- `pnpm run db:migrate:create src/migration/MigrationName` - Create empty migration file
24+
25+
**Migration Generation:**
26+
When adding or modifying entity columns, **always generate a migration** using:
27+
```bash
28+
# IMPORTANT: Run nvm use from within daily-api directory (uses .nvmrc with node 22.16)
29+
cd /path/to/daily-api
30+
nvm use
31+
pnpm run db:migrate:make src/migration/DescriptiveMigrationName
32+
```
33+
The migration generator compares entities against the local database schema. Ensure your local DB is up to date with `pnpm run db:migrate:latest` before generating new migrations.
34+
35+
**IMPORTANT: Review generated migrations for schema drift.** The generator may include unrelated changes from local schema differences. Always review and clean up migrations to include only the intended changes.
2436

2537
**Building & Testing:**
2638
- `pnpm run build` - Compile TypeScript to build directory

__tests__/common/socials.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { detectPlatformFromUrl } from '../../src/common/schema/socials';
2+
3+
describe('detectPlatformFromUrl', () => {
4+
it('should detect twitter.com', () => {
5+
expect(detectPlatformFromUrl('https://twitter.com/username')).toBe(
6+
'twitter',
7+
);
8+
});
9+
10+
it('should detect x.com as twitter', () => {
11+
expect(detectPlatformFromUrl('https://x.com/username')).toBe('twitter');
12+
});
13+
14+
it('should detect github.com', () => {
15+
expect(detectPlatformFromUrl('https://github.com/username')).toBe('github');
16+
});
17+
18+
it('should detect linkedin.com', () => {
19+
expect(detectPlatformFromUrl('https://linkedin.com/in/username')).toBe(
20+
'linkedin',
21+
);
22+
});
23+
24+
it('should detect www. prefixed URLs', () => {
25+
expect(detectPlatformFromUrl('https://www.github.com/user')).toBe('github');
26+
});
27+
28+
it('should detect m. prefixed URLs', () => {
29+
expect(detectPlatformFromUrl('https://m.youtube.com/@channel')).toBe(
30+
'youtube',
31+
);
32+
});
33+
34+
it('should detect threads.net', () => {
35+
expect(detectPlatformFromUrl('https://threads.net/@username')).toBe(
36+
'threads',
37+
);
38+
});
39+
40+
it('should detect bluesky', () => {
41+
expect(
42+
detectPlatformFromUrl('https://bsky.app/profile/user.bsky.social'),
43+
).toBe('bluesky');
44+
});
45+
46+
it('should detect roadmap.sh', () => {
47+
expect(detectPlatformFromUrl('https://roadmap.sh/u/username')).toBe(
48+
'roadmap',
49+
);
50+
});
51+
52+
it('should detect youtube.com', () => {
53+
expect(detectPlatformFromUrl('https://youtube.com/@channel')).toBe(
54+
'youtube',
55+
);
56+
});
57+
58+
it('should detect youtu.be', () => {
59+
expect(detectPlatformFromUrl('https://youtu.be/video123')).toBe('youtube');
60+
});
61+
62+
it('should detect hashnode.com', () => {
63+
expect(detectPlatformFromUrl('https://hashnode.com/@user')).toBe(
64+
'hashnode',
65+
);
66+
});
67+
68+
it('should detect hashnode.dev subdomains', () => {
69+
expect(detectPlatformFromUrl('https://blog.hashnode.dev/post')).toBe(
70+
'hashnode',
71+
);
72+
});
73+
74+
it('should detect mastodon instances with /@ path', () => {
75+
expect(detectPlatformFromUrl('https://mastodon.social/@user')).toBe(
76+
'mastodon',
77+
);
78+
expect(detectPlatformFromUrl('https://fosstodon.org/@user')).toBe(
79+
'mastodon',
80+
);
81+
});
82+
83+
it('should return null for unknown domains', () => {
84+
expect(detectPlatformFromUrl('https://example.com/profile')).toBeNull();
85+
expect(detectPlatformFromUrl('https://mysite.io/about')).toBeNull();
86+
});
87+
88+
it('should return null for invalid URLs', () => {
89+
expect(detectPlatformFromUrl('not-a-url')).toBeNull();
90+
expect(detectPlatformFromUrl('')).toBeNull();
91+
});
92+
});

__tests__/users.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4213,6 +4213,97 @@ describe('mutation updateUserProfile', () => {
42134213
expect(updated?.flags.vordr).toBe(true);
42144214
expect(updated?.flags.trustScore).toBe(0.9);
42154215
});
4216+
4217+
it('should update socialLinks with auto-detected platforms', async () => {
4218+
loggedUser = '1';
4219+
4220+
const res = await client.mutate(MUTATION, {
4221+
variables: {
4222+
data: {
4223+
socialLinks: [
4224+
{ url: 'https://github.com/testuser' },
4225+
{ url: 'https://twitter.com/testhandle' },
4226+
],
4227+
},
4228+
},
4229+
});
4230+
4231+
expect(res.errors).toBeFalsy();
4232+
4233+
const updated = await con.getRepository(User).findOneBy({ id: loggedUser });
4234+
expect(updated?.socialLinks).toEqual([
4235+
{ platform: 'github', url: 'https://github.com/testuser' },
4236+
{ platform: 'twitter', url: 'https://twitter.com/testhandle' },
4237+
]);
4238+
});
4239+
4240+
it('should update socialLinks with explicit platform override', async () => {
4241+
loggedUser = '1';
4242+
4243+
const res = await client.mutate(MUTATION, {
4244+
variables: {
4245+
data: {
4246+
socialLinks: [
4247+
{ url: 'https://example.com/profile', platform: 'custom' },
4248+
],
4249+
},
4250+
},
4251+
});
4252+
4253+
expect(res.errors).toBeFalsy();
4254+
4255+
const updated = await con.getRepository(User).findOneBy({ id: loggedUser });
4256+
expect(updated?.socialLinks).toEqual([
4257+
{ platform: 'custom', url: 'https://example.com/profile' },
4258+
]);
4259+
});
4260+
4261+
it('should dual-write to legacy columns when updating socialLinks', async () => {
4262+
loggedUser = '1';
4263+
4264+
const res = await client.mutate(MUTATION, {
4265+
variables: {
4266+
data: {
4267+
socialLinks: [
4268+
{ url: 'https://github.com/myhandle' },
4269+
{ url: 'https://linkedin.com/in/myprofile' },
4270+
],
4271+
},
4272+
},
4273+
});
4274+
4275+
expect(res.errors).toBeFalsy();
4276+
4277+
const updated = await con.getRepository(User).findOneBy({ id: loggedUser });
4278+
expect(updated?.github).toBe('myhandle');
4279+
expect(updated?.linkedin).toBe('myprofile');
4280+
});
4281+
4282+
it('should clear socialLinks when empty array is provided', async () => {
4283+
loggedUser = '1';
4284+
4285+
// First set some social links
4286+
await con.getRepository(User).update(
4287+
{ id: loggedUser },
4288+
{
4289+
socialLinks: [{ platform: 'github', url: 'https://github.com/test' }],
4290+
},
4291+
);
4292+
4293+
// Then clear them
4294+
const res = await client.mutate(MUTATION, {
4295+
variables: {
4296+
data: {
4297+
socialLinks: [],
4298+
},
4299+
},
4300+
});
4301+
4302+
expect(res.errors).toBeFalsy();
4303+
4304+
const updated = await con.getRepository(User).findOneBy({ id: loggedUser });
4305+
expect(updated?.socialLinks).toEqual([]);
4306+
});
42164307
});
42174308

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

src/common/schema/socials.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,78 @@ export const socialFieldsSchema = z.object({
8282
});
8383

8484
export type SocialFields = z.infer<typeof socialFieldsSchema>;
85+
86+
/**
87+
* Domain-to-platform mapping for auto-detection
88+
*/
89+
const PLATFORM_DOMAINS: Record<string, string> = {
90+
'linkedin.com': 'linkedin',
91+
'github.com': 'github',
92+
'twitter.com': 'twitter',
93+
'x.com': 'twitter',
94+
'threads.net': 'threads',
95+
'bsky.app': 'bluesky',
96+
'roadmap.sh': 'roadmap',
97+
'codepen.io': 'codepen',
98+
'reddit.com': 'reddit',
99+
'stackoverflow.com': 'stackoverflow',
100+
'youtube.com': 'youtube',
101+
'youtu.be': 'youtube',
102+
'hashnode.com': 'hashnode',
103+
'hashnode.dev': 'hashnode',
104+
};
105+
106+
/**
107+
* Detect platform from a URL
108+
* @param url - Full URL to detect platform from
109+
* @returns Platform identifier or null if not detected
110+
*/
111+
export function detectPlatformFromUrl(url: string): string | null {
112+
try {
113+
const hostname = new URL(url).hostname.replace(/^(www\.|m\.)/, '');
114+
115+
// Check for exact matches first
116+
if (PLATFORM_DOMAINS[hostname]) {
117+
return PLATFORM_DOMAINS[hostname];
118+
}
119+
120+
// Check for partial matches (subdomains like mastodon instances)
121+
for (const [domain, platform] of Object.entries(PLATFORM_DOMAINS)) {
122+
if (hostname.endsWith(`.${domain}`) || hostname === domain) {
123+
return platform;
124+
}
125+
}
126+
127+
// Special handling for mastodon instances (format: instance/@username)
128+
if (hostname.match(/^[a-z0-9-]+\.[a-z]{2,}$/) && url.includes('/@')) {
129+
return 'mastodon';
130+
}
131+
132+
return null;
133+
} catch {
134+
return null;
135+
}
136+
}
137+
138+
/**
139+
* Schema for a single social link input
140+
*/
141+
export const socialLinkInputSchema = z.object({
142+
url: z.string().url(),
143+
platform: z.string().optional(),
144+
});
145+
146+
/**
147+
* Schema for socialLinks array input with auto-detection and transformation
148+
*/
149+
export const socialLinksInputSchema = z
150+
.array(socialLinkInputSchema)
151+
.transform((links) =>
152+
links.map(({ url, platform }) => ({
153+
platform: platform || detectPlatformFromUrl(url) || 'other',
154+
url,
155+
})),
156+
);
157+
158+
export type SocialLinkInput = z.input<typeof socialLinkInputSchema>;
159+
export type SocialLink = z.output<typeof socialLinksInputSchema>[number];

src/entity/user/User.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ export type UserNotificationFlags = Partial<
8282
>
8383
>;
8484

85+
export interface UserSocialLink {
86+
platform: string;
87+
url: string;
88+
}
89+
8590
@Entity()
8691
@Index('IDX_user_lowerusername_username', { synchronize: false })
8792
@Index('IDX_user_lowertwitter', { synchronize: false })
@@ -321,6 +326,9 @@ export class User {
321326
@Column({ type: 'jsonb', default: {} })
322327
notificationFlags: UserNotificationFlags;
323328

329+
@Column({ type: 'jsonb', default: [] })
330+
socialLinks: UserSocialLink[];
331+
324332
@OneToOne(
325333
'UserCandidatePreference',
326334
(pref: UserCandidatePreference) => pref.user,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddSocialLinksColumn1767795409185 implements MigrationInterface {
4+
name = 'AddSocialLinksColumn1767795409185';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`ALTER TABLE "user" ADD "socialLinks" jsonb NOT NULL DEFAULT '[]'`,
9+
);
10+
}
11+
12+
public async down(queryRunner: QueryRunner): Promise<void> {
13+
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "socialLinks"`);
14+
}
15+
}

0 commit comments

Comments
 (0)