Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions __tests__/common/socials.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
91 changes: 91 additions & 0 deletions __tests__/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
75 changes: 75 additions & 0 deletions src/common/schema/socials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,78 @@ export const socialFieldsSchema = z.object({
});

export type SocialFields = z.infer<typeof socialFieldsSchema>;

/**
* Domain-to-platform mapping for auto-detection
*/
const PLATFORM_DOMAINS: Record<string, string> = {
'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<typeof socialLinkInputSchema>;
export type SocialLink = z.output<typeof socialLinksInputSchema>[number];
8 changes: 8 additions & 0 deletions src/entity/user/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions src/migration/1767795409185-AddSocialLinksColumn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddSocialLinksColumn1767795409185 implements MigrationInterface {
name = 'AddSocialLinksColumn1767795409185';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" ADD "socialLinks" jsonb NOT NULL DEFAULT '[]'`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "socialLinks"`);
}
}
Loading
Loading