Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateUserProvidersTable1760000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
// Create the user_providers table
await queryRunner.query(`
CREATE TABLE "user_providers" (
"id" varchar PRIMARY KEY NOT NULL,
"user_id" varchar NOT NULL,
"provider_name" varchar(50) NOT NULL,
"provider_user_id" varchar(255) NOT NULL,
"created_at" datetime NOT NULL DEFAULT (datetime('now')),
CONSTRAINT "FK_user_providers_user" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
)
`);

// Create indexes
await queryRunner.query(`
CREATE UNIQUE INDEX "idx_user_providers_lookup" ON "user_providers" ("provider_name", "provider_user_id")
`);

await queryRunner.query(`
CREATE INDEX "idx_user_providers_user_id" ON "user_providers" ("user_id")
`);

// Migrate existing provider_ids data to the new table
// This reads the comma-separated provider_ids and inserts them into user_providers
const users = await queryRunner.query(`
SELECT id, provider_ids FROM users WHERE provider_ids IS NOT NULL AND provider_ids != ''
`);

for (const user of users) {
const providerIds = user.provider_ids
.split(',')
.filter((id: string) => id.trim());

for (const providerId of providerIds) {
const trimmed = providerId.trim();
const separatorIndex = trimmed.indexOf('|');

let providerName: string;
let providerUserId: string;

if (separatorIndex === -1) {
providerName = 'unknown';
providerUserId = trimmed;
} else {
providerName = trimmed.substring(0, separatorIndex);
providerUserId = trimmed.substring(separatorIndex + 1);
}

// Generate a UUID for the new record
const uuid = this.generateUUID();

await queryRunner.query(
`
INSERT OR IGNORE INTO "user_providers" ("id", "user_id", "provider_name", "provider_user_id")
VALUES (?, ?, ?, ?)
`,
[uuid, user.id, providerName, providerUserId],
);
}
}
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Drop indexes
await queryRunner.query(`DROP INDEX IF EXISTS "idx_user_providers_lookup"`);
await queryRunner.query(
`DROP INDEX IF EXISTS "idx_user_providers_user_id"`,
);

// Drop table
await queryRunner.query(`DROP TABLE IF EXISTS "user_providers"`);
}

private generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(
/[xy]/g,
function (c) {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
},
);
}
}
176 changes: 176 additions & 0 deletions apps/backend/src/entities/user-provider.entity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { UserProvider } from './user-provider.entity';

describe('UserProvider', () => {
describe('parseProviderId', () => {
it('should parse google provider ID correctly', () => {
const result = UserProvider.parseProviderId('google|123456789');

expect(result).toEqual({
providerName: 'google',
providerUserId: '123456789',
});
});

it('should parse auth0 provider ID correctly', () => {
const result = UserProvider.parseProviderId('auth0|user_abc123');

expect(result).toEqual({
providerName: 'auth0',
providerUserId: 'user_abc123',
});
});

it('should parse github provider ID correctly', () => {
const result = UserProvider.parseProviderId('github|99887766');

expect(result).toEqual({
providerName: 'github',
providerUserId: '99887766',
});
});

it('should handle provider ID with multiple pipe characters', () => {
// Only first pipe is the separator
const result = UserProvider.parseProviderId('google|id|with|pipes');

expect(result).toEqual({
providerName: 'google',
providerUserId: 'id|with|pipes',
});
});

it('should return "unknown" provider for legacy format without separator', () => {
const result = UserProvider.parseProviderId('legacy_user_id_123');

expect(result).toEqual({
providerName: 'unknown',
providerUserId: 'legacy_user_id_123',
});
});

it('should handle empty provider name (pipe at start)', () => {
const result = UserProvider.parseProviderId('|user123');

expect(result).toEqual({
providerName: '',
providerUserId: 'user123',
});
});

it('should handle empty user ID (pipe at end)', () => {
const result = UserProvider.parseProviderId('google|');

expect(result).toEqual({
providerName: 'google',
providerUserId: '',
});
});

it('should handle just a pipe character', () => {
const result = UserProvider.parseProviderId('|');

expect(result).toEqual({
providerName: '',
providerUserId: '',
});
});

it('should parse provider ID with special characters in user ID', () => {
const result = UserProvider.parseProviderId(
'google-oauth2|1234567890.apps.googleusercontent.com',
);

expect(result).toEqual({
providerName: 'google-oauth2',
providerUserId: '1234567890.apps.googleusercontent.com',
});
});
});

describe('toProviderId', () => {
it('should create provider ID from google name and user ID', () => {
const result = UserProvider.toProviderId('google', '123456789');

expect(result).toBe('google|123456789');
});

it('should create provider ID from auth0 name and user ID', () => {
const result = UserProvider.toProviderId('auth0', 'user_abc123');

expect(result).toBe('auth0|user_abc123');
});

it('should handle empty strings', () => {
const result = UserProvider.toProviderId('', '');

expect(result).toBe('|');
});

it('should handle special characters in user ID', () => {
const result = UserProvider.toProviderId(
'google-oauth2',
'1234567890.apps.googleusercontent.com',
);

expect(result).toBe('google-oauth2|1234567890.apps.googleusercontent.com');
});
});

describe('parseProviderId and toProviderId roundtrip', () => {
it('should roundtrip standard provider IDs', () => {
const original = 'google|123456789';
const parsed = UserProvider.parseProviderId(original);
const reconstructed = UserProvider.toProviderId(
parsed.providerName,
parsed.providerUserId,
);

expect(reconstructed).toBe(original);
});

it('should roundtrip auth0 provider IDs', () => {
const original = 'auth0|user_12345';
const parsed = UserProvider.parseProviderId(original);
const reconstructed = UserProvider.toProviderId(
parsed.providerName,
parsed.providerUserId,
);

expect(reconstructed).toBe(original);
});

it('should roundtrip complex provider IDs', () => {
const original = 'google-oauth2|id|with|multiple|pipes';
const parsed = UserProvider.parseProviderId(original);
const reconstructed = UserProvider.toProviderId(
parsed.providerName,
parsed.providerUserId,
);

expect(reconstructed).toBe(original);
});
});

describe('entity structure', () => {
it('should create entity instance with all fields', () => {
const provider = new UserProvider();
provider.id = 'test-uuid';
provider.userId = 'user-123';
provider.providerName = 'google';
provider.providerUserId = '12345';
provider.createdAt = new Date('2024-01-01');

expect(provider.id).toBe('test-uuid');
expect(provider.userId).toBe('user-123');
expect(provider.providerName).toBe('google');
expect(provider.providerUserId).toBe('12345');
expect(provider.createdAt).toEqual(new Date('2024-01-01'));
});

it('should have user relation defined', () => {
const provider = new UserProvider();
// User relation should be undefined initially
expect(provider.user).toBeUndefined();
});
});
});
69 changes: 69 additions & 0 deletions apps/backend/src/entities/user-provider.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
Index,
} from 'typeorm';
import { User } from './user.entity';

/**
* Join table for User to Identity Provider relationship.
* Eliminates database-specific provider ID queries by using
* standard SQL JOINs instead of array contains operations.
*
* Supports multiple identity providers per user (Google, Auth0, GitHub, etc.)
*/
@Entity('user_providers')
@Index('idx_user_providers_lookup', ['providerName', 'providerUserId'], {
unique: true,
})
@Index('idx_user_providers_user_id', ['userId'])
export class UserProvider {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ name: 'user_id' })
userId: string;

@Column({ name: 'provider_name', type: 'varchar', length: 50 })
providerName: string;

@Column({ name: 'provider_user_id', type: 'varchar', length: 255 })
providerUserId: string;

@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

@ManyToOne(() => User, (user) => user.providers, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;

/**
* Parse a combined provider ID like "google|123456" into name and user ID
*/
static parseProviderId(providerId: string): {
providerName: string;
providerUserId: string;
} {
const separatorIndex = providerId.indexOf('|');
if (separatorIndex === -1) {
// Legacy format or unknown, use full string as ID with 'unknown' provider
return { providerName: 'unknown', providerUserId: providerId };
}

return {
providerName: providerId.substring(0, separatorIndex),
providerUserId: providerId.substring(separatorIndex + 1),
};
}

/**
* Create a combined provider ID from name and user ID
*/
static toProviderId(providerName: string, providerUserId: string): string {
return `${providerName}|${providerUserId}`;
}
}
Loading