diff --git a/AGENTS.md b/AGENTS.md index dfd293b4a4..4707a4df4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,20 @@ This file provides guidance to coding agents when working with code in this repo - `pnpm run db:migrate:latest` - Apply latest migrations - `pnpm run db:migrate:reset` - Drop schema and rerun migrations - `pnpm run db:seed:import` - Import seed data for local development -- `pnpm run db:migrate:make` - Generate new migration based on entity changes -- `pnpm run db:migrate:create` - Create empty migration file +- `pnpm run db:migrate:make src/migration/MigrationName` - Generate new migration based on entity changes +- `pnpm run db:migrate:create src/migration/MigrationName` - Create empty migration file + +**Migration Generation:** +When adding or modifying entity columns, **always generate a migration** using: +```bash +# IMPORTANT: Run nvm use from within daily-api directory (uses .nvmrc with node 22.16) +cd /path/to/daily-api +nvm use +pnpm run db:migrate:make src/migration/DescriptiveMigrationName +``` +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. + +**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. **Building & Testing:** - `pnpm run build` - Compile TypeScript to build directory diff --git a/__tests__/common/socials.ts b/__tests__/common/socials.ts new file mode 100644 index 0000000000..ecbe97e6c6 --- /dev/null +++ b/__tests__/common/socials.ts @@ -0,0 +1,92 @@ +import { detectPlatformFromUrl } from '../../src/common/schema/socials'; + +describe('detectPlatformFromUrl', () => { + it('should detect twitter.com', () => { + expect(detectPlatformFromUrl('https://twitter.com/username')).toBe( + 'twitter', + ); + }); + + it('should detect x.com as twitter', () => { + expect(detectPlatformFromUrl('https://x.com/username')).toBe('twitter'); + }); + + it('should detect github.com', () => { + expect(detectPlatformFromUrl('https://github.com/username')).toBe('github'); + }); + + it('should detect linkedin.com', () => { + expect(detectPlatformFromUrl('https://linkedin.com/in/username')).toBe( + 'linkedin', + ); + }); + + it('should detect www. prefixed URLs', () => { + expect(detectPlatformFromUrl('https://www.github.com/user')).toBe('github'); + }); + + it('should detect m. prefixed URLs', () => { + expect(detectPlatformFromUrl('https://m.youtube.com/@channel')).toBe( + 'youtube', + ); + }); + + it('should detect threads.net', () => { + expect(detectPlatformFromUrl('https://threads.net/@username')).toBe( + 'threads', + ); + }); + + it('should detect bluesky', () => { + expect( + detectPlatformFromUrl('https://bsky.app/profile/user.bsky.social'), + ).toBe('bluesky'); + }); + + it('should detect roadmap.sh', () => { + expect(detectPlatformFromUrl('https://roadmap.sh/u/username')).toBe( + 'roadmap', + ); + }); + + it('should detect youtube.com', () => { + expect(detectPlatformFromUrl('https://youtube.com/@channel')).toBe( + 'youtube', + ); + }); + + it('should detect youtu.be', () => { + expect(detectPlatformFromUrl('https://youtu.be/video123')).toBe('youtube'); + }); + + it('should detect hashnode.com', () => { + expect(detectPlatformFromUrl('https://hashnode.com/@user')).toBe( + 'hashnode', + ); + }); + + it('should detect hashnode.dev subdomains', () => { + expect(detectPlatformFromUrl('https://blog.hashnode.dev/post')).toBe( + 'hashnode', + ); + }); + + it('should detect mastodon instances with /@ path', () => { + expect(detectPlatformFromUrl('https://mastodon.social/@user')).toBe( + 'mastodon', + ); + expect(detectPlatformFromUrl('https://fosstodon.org/@user')).toBe( + 'mastodon', + ); + }); + + it('should return null for unknown domains', () => { + expect(detectPlatformFromUrl('https://example.com/profile')).toBeNull(); + expect(detectPlatformFromUrl('https://mysite.io/about')).toBeNull(); + }); + + it('should return null for invalid URLs', () => { + expect(detectPlatformFromUrl('not-a-url')).toBeNull(); + expect(detectPlatformFromUrl('')).toBeNull(); + }); +}); diff --git a/__tests__/users.ts b/__tests__/users.ts index a025d4cba8..bf799c50ad 100644 --- a/__tests__/users.ts +++ b/__tests__/users.ts @@ -4213,6 +4213,97 @@ describe('mutation updateUserProfile', () => { expect(updated?.flags.vordr).toBe(true); expect(updated?.flags.trustScore).toBe(0.9); }); + + it('should update socialLinks with auto-detected platforms', async () => { + loggedUser = '1'; + + const res = await client.mutate(MUTATION, { + variables: { + data: { + socialLinks: [ + { url: 'https://github.com/testuser' }, + { url: 'https://twitter.com/testhandle' }, + ], + }, + }, + }); + + expect(res.errors).toBeFalsy(); + + const updated = await con.getRepository(User).findOneBy({ id: loggedUser }); + expect(updated?.socialLinks).toEqual([ + { platform: 'github', url: 'https://github.com/testuser' }, + { platform: 'twitter', url: 'https://twitter.com/testhandle' }, + ]); + }); + + it('should update socialLinks with explicit platform override', async () => { + loggedUser = '1'; + + const res = await client.mutate(MUTATION, { + variables: { + data: { + socialLinks: [ + { url: 'https://example.com/profile', platform: 'custom' }, + ], + }, + }, + }); + + expect(res.errors).toBeFalsy(); + + const updated = await con.getRepository(User).findOneBy({ id: loggedUser }); + expect(updated?.socialLinks).toEqual([ + { platform: 'custom', url: 'https://example.com/profile' }, + ]); + }); + + it('should dual-write to legacy columns when updating socialLinks', async () => { + loggedUser = '1'; + + const res = await client.mutate(MUTATION, { + variables: { + data: { + socialLinks: [ + { url: 'https://github.com/myhandle' }, + { url: 'https://linkedin.com/in/myprofile' }, + ], + }, + }, + }); + + expect(res.errors).toBeFalsy(); + + const updated = await con.getRepository(User).findOneBy({ id: loggedUser }); + expect(updated?.github).toBe('myhandle'); + expect(updated?.linkedin).toBe('myprofile'); + }); + + it('should clear socialLinks when empty array is provided', async () => { + loggedUser = '1'; + + // First set some social links + await con.getRepository(User).update( + { id: loggedUser }, + { + socialLinks: [{ platform: 'github', url: 'https://github.com/test' }], + }, + ); + + // Then clear them + const res = await client.mutate(MUTATION, { + variables: { + data: { + socialLinks: [], + }, + }, + }); + + expect(res.errors).toBeFalsy(); + + const updated = await con.getRepository(User).findOneBy({ id: loggedUser }); + expect(updated?.socialLinks).toEqual([]); + }); }); describe('mutation deleteUser', () => { diff --git a/src/common/schema/socials.ts b/src/common/schema/socials.ts index 99ba8c2ce6..a31b7ee759 100644 --- a/src/common/schema/socials.ts +++ b/src/common/schema/socials.ts @@ -82,3 +82,78 @@ export const socialFieldsSchema = z.object({ }); export type SocialFields = z.infer; + +/** + * Domain-to-platform mapping for auto-detection + */ +const PLATFORM_DOMAINS: Record = { + 'linkedin.com': 'linkedin', + 'github.com': 'github', + 'twitter.com': 'twitter', + 'x.com': 'twitter', + 'threads.net': 'threads', + 'bsky.app': 'bluesky', + 'roadmap.sh': 'roadmap', + 'codepen.io': 'codepen', + 'reddit.com': 'reddit', + 'stackoverflow.com': 'stackoverflow', + 'youtube.com': 'youtube', + 'youtu.be': 'youtube', + 'hashnode.com': 'hashnode', + 'hashnode.dev': 'hashnode', +}; + +/** + * Detect platform from a URL + * @param url - Full URL to detect platform from + * @returns Platform identifier or null if not detected + */ +export function detectPlatformFromUrl(url: string): string | null { + try { + const hostname = new URL(url).hostname.replace(/^(www\.|m\.)/, ''); + + // Check for exact matches first + if (PLATFORM_DOMAINS[hostname]) { + return PLATFORM_DOMAINS[hostname]; + } + + // Check for partial matches (subdomains like mastodon instances) + for (const [domain, platform] of Object.entries(PLATFORM_DOMAINS)) { + if (hostname.endsWith(`.${domain}`) || hostname === domain) { + return platform; + } + } + + // Special handling for mastodon instances (format: instance/@username) + if (hostname.match(/^[a-z0-9-]+\.[a-z]{2,}$/) && url.includes('/@')) { + return 'mastodon'; + } + + return null; + } catch { + return null; + } +} + +/** + * Schema for a single social link input + */ +export const socialLinkInputSchema = z.object({ + url: z.string().url(), + platform: z.string().optional(), +}); + +/** + * Schema for socialLinks array input with auto-detection and transformation + */ +export const socialLinksInputSchema = z + .array(socialLinkInputSchema) + .transform((links) => + links.map(({ url, platform }) => ({ + platform: platform || detectPlatformFromUrl(url) || 'other', + url, + })), + ); + +export type SocialLinkInput = z.input; +export type SocialLink = z.output[number]; diff --git a/src/entity/user/User.ts b/src/entity/user/User.ts index e1b1dc12a3..beddf9cc88 100644 --- a/src/entity/user/User.ts +++ b/src/entity/user/User.ts @@ -82,6 +82,11 @@ export type UserNotificationFlags = Partial< > >; +export interface UserSocialLink { + platform: string; + url: string; +} + @Entity() @Index('IDX_user_lowerusername_username', { synchronize: false }) @Index('IDX_user_lowertwitter', { synchronize: false }) @@ -321,6 +326,9 @@ export class User { @Column({ type: 'jsonb', default: {} }) notificationFlags: UserNotificationFlags; + @Column({ type: 'jsonb', default: [] }) + socialLinks: UserSocialLink[]; + @OneToOne( 'UserCandidatePreference', (pref: UserCandidatePreference) => pref.user, diff --git a/src/migration/1767795409185-AddSocialLinksColumn.ts b/src/migration/1767795409185-AddSocialLinksColumn.ts new file mode 100644 index 0000000000..0d1032b794 --- /dev/null +++ b/src/migration/1767795409185-AddSocialLinksColumn.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSocialLinksColumn1767795409185 implements MigrationInterface { + name = 'AddSocialLinksColumn1767795409185'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "socialLinks" jsonb NOT NULL DEFAULT '[]'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "socialLinks"`); + } +} diff --git a/src/schema/users.ts b/src/schema/users.ts index 42e0141f9a..b27301091e 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -29,7 +29,8 @@ import { UserStreakActionType, View, } from '../entity'; -import { UserNotificationFlags } from '../entity/user/User'; +import { UserNotificationFlags, UserSocialLink } from '../entity/user/User'; +import { socialLinksInputSchema } from '../common/schema/socials'; import { AuthenticationError, ForbiddenError, @@ -198,6 +199,7 @@ export interface GQLUpdateUserInput { defaultFeedId?: string; flags: UserFlagsPublic; notificationFlags?: UserNotificationFlags; + socialLinks?: Array<{ url: string; platform?: string }>; } export interface GQLUpdateUserInfoInput extends GQLUpdateUserInput { @@ -250,6 +252,7 @@ export interface GQLUser { coresRole: CoresRole; isPlus?: boolean; notificationFlags: UserNotificationFlags; + socialLinks: UserSocialLink[]; } export interface GQLView { @@ -317,6 +320,34 @@ export interface SendReportArgs { } export const typeDefs = /* GraphQL */ ` + """ + Social media link for a user profile + """ + type UserSocialLink { + """ + Platform identifier (e.g., "twitter", "github", "linkedin") + """ + platform: String! + """ + Full URL to the social profile + """ + url: String! + } + + """ + Input for creating/updating a social link + """ + input UserSocialLinkInput { + """ + Full URL to the social profile - platform will be auto-detected + """ + url: String! + """ + Optional platform override if auto-detection fails + """ + platform: String + } + type Company { id: String! name: String! @@ -416,55 +447,55 @@ export const typeDefs = /* GraphQL */ ` """ Twitter handle of the user """ - twitter: String + twitter: String @deprecated(reason: "Use socialLinks field") """ Github handle of the user """ - github: String + github: String @deprecated(reason: "Use socialLinks field") """ Hashnode handle of the user """ - hashnode: String + hashnode: String @deprecated(reason: "Use socialLinks field") """ Roadmap profile of the user """ - roadmap: String + roadmap: String @deprecated(reason: "Use socialLinks field") """ Threads profile of the user """ - threads: String + threads: String @deprecated(reason: "Use socialLinks field") """ Codepen profile of the user """ - codepen: String + codepen: String @deprecated(reason: "Use socialLinks field") """ Reddit profile of the user """ - reddit: String + reddit: String @deprecated(reason: "Use socialLinks field") """ Stackoverflow profile of the user """ - stackoverflow: String + stackoverflow: String @deprecated(reason: "Use socialLinks field") """ Youtube profile of the user """ - youtube: String + youtube: String @deprecated(reason: "Use socialLinks field") """ Linkedin profile of the user """ - linkedin: String + linkedin: String @deprecated(reason: "Use socialLinks field") """ Mastodon profile of the user """ - mastodon: String + mastodon: String @deprecated(reason: "Use socialLinks field") """ Bluesky profile of the user """ - bluesky: String + bluesky: String @deprecated(reason: "Use socialLinks field") """ Portfolio URL of the user """ - portfolio: String + portfolio: String @deprecated(reason: "Use socialLinks field") """ Date when the user joined """ @@ -538,6 +569,10 @@ export const typeDefs = /* GraphQL */ ` Whether to hide user's experience """ hideExperience: Boolean + """ + Flexible social media links array (replaces individual social fields) + """ + socialLinks: [UserSocialLink!]! } """ @@ -605,51 +640,51 @@ export const typeDefs = /* GraphQL */ ` """ Twitter handle of the user """ - twitter: String + twitter: String @deprecated(reason: "Use socialLinks field") """ Github handle of the user """ - github: String + github: String @deprecated(reason: "Use socialLinks field") """ Hashnode handle of the user """ - hashnode: String + hashnode: String @deprecated(reason: "Use socialLinks field") """ Bluesky profile of the user """ - bluesky: String + bluesky: String @deprecated(reason: "Use socialLinks field") """ Roadmap profile of the user """ - roadmap: String + roadmap: String @deprecated(reason: "Use socialLinks field") """ Threads profile of the user """ - threads: String + threads: String @deprecated(reason: "Use socialLinks field") """ Codepen profile of the user """ - codepen: String + codepen: String @deprecated(reason: "Use socialLinks field") """ Reddit profile of the user """ - reddit: String + reddit: String @deprecated(reason: "Use socialLinks field") """ Stackoverflow profile of the user """ - stackoverflow: String + stackoverflow: String @deprecated(reason: "Use socialLinks field") """ Youtube profile of the user """ - youtube: String + youtube: String @deprecated(reason: "Use socialLinks field") """ Linkedin profile of the user """ - linkedin: String + linkedin: String @deprecated(reason: "Use socialLinks field") """ Mastodon profile of the user """ - mastodon: String + mastodon: String @deprecated(reason: "Use socialLinks field") """ Preferred timezone of the user that affects data """ @@ -669,7 +704,7 @@ export const typeDefs = /* GraphQL */ ` """ User website """ - portfolio: String + portfolio: String @deprecated(reason: "Use socialLinks field") """ If the user has accepted marketing """ @@ -694,6 +729,10 @@ export const typeDefs = /* GraphQL */ ` Flags for the user """ flags: UserFlagsPublic + """ + Flexible social media links (replaces individual social fields) + """ + socialLinks: [UserSocialLinkInput!] } """ @@ -727,51 +766,51 @@ export const typeDefs = /* GraphQL */ ` """ Twitter handle of the user """ - twitter: String + twitter: String @deprecated(reason: "Use socialLinks field") """ Github handle of the user """ - github: String + github: String @deprecated(reason: "Use socialLinks field") """ Hashnode handle of the user """ - hashnode: String + hashnode: String @deprecated(reason: "Use socialLinks field") """ Bluesky profile of the user """ - bluesky: String + bluesky: String @deprecated(reason: "Use socialLinks field") """ Roadmap profile of the user """ - roadmap: String + roadmap: String @deprecated(reason: "Use socialLinks field") """ Threads profile of the user """ - threads: String + threads: String @deprecated(reason: "Use socialLinks field") """ Codepen profile of the user """ - codepen: String + codepen: String @deprecated(reason: "Use socialLinks field") """ Reddit profile of the user """ - reddit: String + reddit: String @deprecated(reason: "Use socialLinks field") """ Stackoverflow profile of the user """ - stackoverflow: String + stackoverflow: String @deprecated(reason: "Use socialLinks field") """ Youtube profile of the user """ - youtube: String + youtube: String @deprecated(reason: "Use socialLinks field") """ Linkedin profile of the user """ - linkedin: String + linkedin: String @deprecated(reason: "Use socialLinks field") """ Mastodon profile of the user """ - mastodon: String + mastodon: String @deprecated(reason: "Use socialLinks field") """ Preferred timezone of the user that affects data """ @@ -791,7 +830,7 @@ export const typeDefs = /* GraphQL */ ` """ User website """ - portfolio: String + portfolio: String @deprecated(reason: "Use socialLinks field") """ If the user has accepted marketing """ @@ -832,6 +871,10 @@ export const typeDefs = /* GraphQL */ ` Whether to hide user's experience """ hideExperience: Boolean + """ + Flexible social media links (replaces individual social fields) + """ + socialLinks: [UserSocialLinkInput!] } type TagsReadingStatus { @@ -1673,6 +1716,123 @@ export const clearImagePreset = async ({ } }; +/** + * 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': + // Full URL is stored for hashnode + return url; + 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 + */ +function processSocialLinksForDualWrite( + socialLinksInput: Array<{ url: string; platform?: string }>, +): { + socialLinks: UserSocialLink[]; + legacyColumns: Partial< + Record< + | 'twitter' + | 'github' + | 'linkedin' + | 'threads' + | 'roadmap' + | 'codepen' + | 'reddit' + | 'stackoverflow' + | 'youtube' + | 'bluesky' + | 'mastodon' + | 'hashnode' + | 'portfolio', + string | null + > + >; +} { + // Validate and transform using Zod schema + const validated = socialLinksInputSchema.parse(socialLinksInput); + + // Build legacy column values (first occurrence wins) + const legacyColumns: Record = {}; + const supportedPlatforms = [ + 'twitter', + 'github', + 'linkedin', + 'threads', + 'roadmap', + 'codepen', + 'reddit', + 'stackoverflow', + 'youtube', + 'bluesky', + 'mastodon', + 'hashnode', + 'portfolio', + ]; + + for (const { platform, url } of validated) { + if (supportedPlatforms.includes(platform) && !legacyColumns[platform]) { + legacyColumns[platform] = extractHandleFromUrl(url, platform); + } + } + + return { + socialLinks: validated, + legacyColumns, + }; +} + export const resolvers: IResolvers = traceResolvers< unknown, BaseContext @@ -2423,7 +2583,22 @@ export const resolvers: IResolvers = traceResolvers< : data.image || user.image; try { - const updatedUser = { ...user, ...data, image: avatar }; + // Process socialLinks for dual-write if provided + let socialLinksData: { + socialLinks?: UserSocialLink[]; + legacyColumns?: Record; + } = {}; + if (data.socialLinks) { + socialLinksData = processSocialLinksForDualWrite(data.socialLinks); + } + + const updatedUser = { + ...user, + ...data, + image: avatar, + ...(socialLinksData.legacyColumns || {}), + socialLinks: socialLinksData.socialLinks ?? user.socialLinks, + }; updatedUser.email = updatedUser.email?.toLowerCase(); const marketingFlag = updatedUser.acceptedMarketing @@ -2558,6 +2733,15 @@ export const resolvers: IResolvers = traceResolvers< try { delete data.externalLocationId; + // Process socialLinks for dual-write if provided + let socialLinksData: { + socialLinks?: UserSocialLink[]; + legacyColumns?: Record; + } = {}; + if (data.socialLinks) { + socialLinksData = processSocialLinksForDualWrite(data.socialLinks); + } + const updatedUser = { ...user, ...data, @@ -2565,6 +2749,8 @@ export const resolvers: IResolvers = traceResolvers< cover, readmeHtml, locationId: location?.id || null, + ...(socialLinksData.legacyColumns || {}), + socialLinks: socialLinksData.socialLinks ?? user.socialLinks, }; updatedUser.email = updatedUser.email?.toLowerCase();