Skip to content

Commit ac322ca

Browse files
authored
feat: unify and improve identity building across snowflake connectors (#3997)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent b9c487a commit ac322ca

6 files changed

Lines changed: 122 additions & 198 deletions

File tree

.claude/skills/scaffold-snowflake-connector/SKILL.md

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ Ask the user:
9393
If the user provides a path: verify it by checking `{path}/services/apps/snowflake_connectors/src/integrations/index.ts` exists. If confirmed, set `CROWD_DEV_ROOT = {path}`. Proceed to Phase 1.
9494

9595
If the path doesn't exist or they need to clone:
96-
> "Run: `git clone git@github.com:linuxfoundation/crowd.dev.git`
96+
> "Run: `git clone https://github.com/linuxfoundation/crowd.dev.git`
9797
> Then provide the path to the cloned directory."
9898
9999
Wait for a valid path before continuing.
@@ -307,12 +307,20 @@ Example format for an ambiguity:
307307
308308
### 3a. Identity Mapping
309309
310-
**Rule:** Every member record must produce at least one identity with `type: MemberIdentityType.USERNAME`. The fallback chain is (try in order):
311-
1. Platform-native username column from schema
312-
2. LFID column value (used as username on `PlatformType.LFID`)
313-
3. Email value used as the platform USERNAME (last resort)
310+
**Rule:** Every member record must produce at least one identity with `type: MemberIdentityType.USERNAME`. The standard approach is to use the unified `buildMemberIdentities()` method on `TransformerBase` (added in the identity deduplication refactor). Only fall back to inline identity construction if the user explicitly requests it and can justify why the unified method cannot be used (e.g., fundamentally different identity shape not covered by the method's logic).
314311
315-
If Pre-Analysis resolved email, username, and LFID columns with HIGH confidence and the user confirmed them, skip to the summary step below.
312+
The unified method covers the standard fallback chain automatically. Full behavior by case:
313+
314+
| `platformUsername` | `lfUsername` | Identities produced |
315+
|---|---|---|
316+
| null | null | EMAIL(platform) + USERNAME(platform, email) |
317+
| set | null | EMAIL(platform) + USERNAME(platform, platformUsername) |
318+
| null | set | EMAIL(platform) + USERNAME(LFID, lfUsername) + USERNAME(platform, lfUsername) |
319+
| set | set | EMAIL(platform) + USERNAME(platform, platformUsername) + USERNAME(LFID, lfUsername) |
320+
321+
**Critical:** Never pass `lfUsername` as `platformUsername`. When a source only has an LFID column (no platform-native username), pass `platformUsername: null` — the lfUsername-only path (row 3 above) already produces the correct USERNAME identity for the platform using the lfUsername value.
322+
323+
If Pre-Analysis resolved email, platformUsername, and LFID columns with HIGH confidence and the user confirmed them, skip to the summary step below.
316324
317325
For any unresolved identity field, use this pattern:
318326
- **Multiple candidates found**: "I see columns `A` and `B` that could be the email — which one?" (present choices, not open-ended)
@@ -325,8 +333,10 @@ For each confirmed identity column also confirm:
325333
326334
**Critical:** If a JOIN table for users is NOT the same table used by an existing implementation, validate every column explicitly regardless of Pre-Analysis confidence. Column name heuristics alone are not sufficient for unknown tables.
327335
328-
After all identity fields are confirmed, summarize the full identity-building logic and ask:
329-
> "Here is how identities will be built: [summary]. Does this look correct?"
336+
After all identity fields are confirmed, summarize how `buildMemberIdentities()` will be called and ask:
337+
> "Here is how identities will be built:
338+
> `this.buildMemberIdentities({ email, sourceId: [col or null], platformUsername: [col or null], lfUsername: [col or null] })`
339+
> Does this look correct?"
330340
331341
---
332342
@@ -578,11 +588,16 @@ File: `services/apps/snowflake_connectors/src/integrations/{platform}/{source}/t
578588
- All string comparisons must be case-insensitive: use `.toLowerCase()` on both sides of comparison only; preserve the original value in the output
579589
- No broad `else` statements — every branch must have an explicit condition
580590
- All column names referenced in code must exactly match the schema registry — never assumed
581-
- Identity fallback chain (always produces at least one USERNAME identity):
582-
1. If platform-native username column present and non-null → push EMAIL + USERNAME identities for platform
583-
2. Else if LFID present and non-null → push EMAIL for platform + LFID value as USERNAME for platform
584-
3. Else → push EMAIL value as USERNAME for platform (email-as-username)
585-
- After building platform identities, if LFID column is present and non-null → push separate LFID identity: `{ platform: PlatformType.LFID, value: lfid, type: MemberIdentityType.USERNAME, ... }`
591+
- **Identity building — always use `this.buildMemberIdentities()` first (preferred):**
592+
- Call `this.buildMemberIdentities({ email, sourceId, platformUsername, lfUsername })` from `TransformerBase`
593+
- Always pass all 4 arguments explicitly, even when the value is `null` or `undefined` — never omit an argument
594+
- `sourceId` = the user ID column from the source table (`undefined` if the table has no user ID column)
595+
- `platformUsername` = the platform-native username column (`null` if absent — do NOT substitute `lfUsername` here)
596+
- `lfUsername` = the LFID column value (`null` if absent)
597+
- **Never pass `lfUsername` as `platformUsername`** — when a source only has an LFID column, pass `platformUsername: null`; the method's lfUsername-only path already produces the correct platform USERNAME from the lfUsername value
598+
- The method handles the full fallback chain automatically (see the 4-case table in §3a)
599+
- Do NOT import `IMemberData` or `MemberIdentityType` in the transformer — those are only needed if falling back to inline construction
600+
- **Only use inline identity construction if the user explicitly requests it and justifies why `buildMemberIdentities()` cannot be used** (e.g., non-standard identity shape not covered by the method). Document the justification in a comment.
586601
- `isIndividualNoAccount` must call `this.isIndividualNoAccount(displayName)` from `TransformerBase` — never reimplement
587602
- **Do not set member attributes** (e.g., `MemberAttributeName.JOB_TITLE`, `AVATAR_URL`, `COUNTRY`) unless: (a) the user explicitly requested them, or (b) the same table and column are already used for that attribute in an existing implementation — in which case follow the existing pattern exactly
588603
- Extends the platform base class if one was confirmed in Phase 3e; otherwise extends `TransformerBase` directly

services/apps/snowflake_connectors/src/core/transformerBase.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* how raw exported data is transformed into activities.
66
*/
77
import { getServiceChildLogger } from '@crowd/logging'
8-
import { IActivityData, PlatformType } from '@crowd/types'
8+
import { IActivityData, IMemberData, MemberIdentityType, PlatformType } from '@crowd/types'
99

1010
const log = getServiceChildLogger('transformer')
1111

@@ -38,6 +38,70 @@ export abstract class TransformerBase {
3838
return TransformerBase.INDIVIDUAL_NO_ACCOUNT_RE.test(displayName.trim())
3939
}
4040

41+
protected buildMemberIdentities(params: {
42+
email: string
43+
sourceId?: string
44+
platformUsername?: string | null
45+
lfUsername?: string | null
46+
}): IMemberData['identities'] {
47+
const { email, sourceId, platformUsername, lfUsername } = params
48+
const identities: IMemberData['identities'] = [
49+
{
50+
platform: this.platform,
51+
value: email,
52+
type: MemberIdentityType.EMAIL,
53+
verified: true,
54+
verifiedBy: this.platform,
55+
sourceId,
56+
},
57+
]
58+
if (!lfUsername && !platformUsername) {
59+
identities.push({
60+
platform: this.platform,
61+
value: email,
62+
type: MemberIdentityType.USERNAME,
63+
verified: true,
64+
verifiedBy: this.platform,
65+
sourceId,
66+
})
67+
return identities
68+
}
69+
70+
if (platformUsername) {
71+
identities.push({
72+
platform: this.platform,
73+
value: platformUsername,
74+
type: MemberIdentityType.USERNAME,
75+
verified: true,
76+
verifiedBy: this.platform,
77+
sourceId,
78+
})
79+
}
80+
81+
if (lfUsername) {
82+
identities.push({
83+
platform: PlatformType.LFID,
84+
value: lfUsername,
85+
type: MemberIdentityType.USERNAME,
86+
verified: true,
87+
verifiedBy: this.platform,
88+
sourceId,
89+
})
90+
if (!platformUsername) {
91+
identities.push({
92+
platform: this.platform,
93+
value: lfUsername,
94+
type: MemberIdentityType.USERNAME,
95+
verified: true,
96+
verifiedBy: this.platform,
97+
sourceId,
98+
})
99+
}
100+
}
101+
102+
return identities
103+
}
104+
41105
/**
42106
* Safe wrapper around transformRow that catches errors and returns null.
43107
*/

services/apps/snowflake_connectors/src/integrations/cvent/event-registrations/transformer.ts

Lines changed: 7 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@ import { CVENT_GRID, CventActivityType } from '@crowd/integrations'
22
import { getServiceChildLogger } from '@crowd/logging'
33
import {
44
IActivityData,
5-
IMemberData,
65
IOrganizationIdentity,
76
MemberAttributeName,
8-
MemberIdentityType,
97
OrganizationIdentityType,
108
OrganizationSource,
119
PlatformType,
@@ -34,55 +32,19 @@ export class CventTransformer extends TransformerBase {
3432
}
3533

3634
const registrationId = (row.REGISTRATION_ID as string)?.trim()
35+
const sourceId = (row.USER_ID as string | null) || undefined
3736

3837
const displayName =
3938
fullName ||
4039
(firstName && lastName ? `${firstName} ${lastName}` : firstName || lastName) ||
4140
userName
4241

43-
const identities: IMemberData['identities'] = []
44-
const sourceId = (row.USER_ID as string | null) || undefined
45-
46-
if (userName) {
47-
identities.push(
48-
{
49-
platform: PlatformType.CVENT,
50-
value: email,
51-
type: MemberIdentityType.EMAIL,
52-
verified: true,
53-
verifiedBy: PlatformType.CVENT,
54-
sourceId,
55-
},
56-
{
57-
platform: PlatformType.CVENT,
58-
value: userName,
59-
type: MemberIdentityType.USERNAME,
60-
verified: true,
61-
verifiedBy: PlatformType.CVENT,
62-
sourceId,
63-
},
64-
)
65-
} else {
66-
identities.push({
67-
platform: PlatformType.CVENT,
68-
value: email,
69-
type: MemberIdentityType.USERNAME,
70-
verified: true,
71-
verifiedBy: PlatformType.CVENT,
72-
sourceId,
73-
})
74-
}
75-
76-
if (lfUsername) {
77-
identities.push({
78-
platform: PlatformType.LFID,
79-
value: lfUsername,
80-
type: MemberIdentityType.USERNAME,
81-
verified: true,
82-
verifiedBy: PlatformType.CVENT,
83-
sourceId,
84-
})
85-
}
42+
const identities = this.buildMemberIdentities({
43+
email,
44+
sourceId,
45+
platformUsername: userName,
46+
lfUsername,
47+
})
8648

8749
const type =
8850
row.USER_ATTENDED === true

services/apps/snowflake_connectors/src/integrations/tnc/certificates/transformer.ts

Lines changed: 7 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
import { TNC_GRID, TncActivityType } from '@crowd/integrations'
22
import { getServiceChildLogger } from '@crowd/logging'
3-
import {
4-
IActivityData,
5-
IMemberData,
6-
MemberAttributeName,
7-
MemberIdentityType,
8-
PlatformType,
9-
} from '@crowd/types'
3+
import { IActivityData, MemberAttributeName, PlatformType } from '@crowd/types'
104

115
import { TransformedActivity } from '../../../core/transformerBase'
126
import { TncTransformerBase } from '../tncTransformerBase'
@@ -24,47 +18,14 @@ export class TncCertificatesTransformer extends TncTransformerBase {
2418
const certificateId = (row.CERTIFICATE_ID as string)?.trim()
2519
const learnerName = (row.LEARNER_NAME as string | null)?.trim() || null
2620
const lfUsername = (row.LFID as string | null)?.trim() || null
27-
28-
const identities: IMemberData['identities'] = []
2921
const sourceId = (row.USER_ID as string | null) || undefined
3022

31-
if (lfUsername) {
32-
identities.push(
33-
{
34-
platform: PlatformType.TNC,
35-
value: email,
36-
type: MemberIdentityType.EMAIL,
37-
verified: true,
38-
verifiedBy: PlatformType.TNC,
39-
sourceId,
40-
},
41-
{
42-
platform: PlatformType.TNC,
43-
value: lfUsername,
44-
type: MemberIdentityType.USERNAME,
45-
verified: true,
46-
verifiedBy: PlatformType.TNC,
47-
sourceId,
48-
},
49-
{
50-
platform: PlatformType.LFID,
51-
value: lfUsername,
52-
type: MemberIdentityType.USERNAME,
53-
verified: true,
54-
verifiedBy: PlatformType.TNC,
55-
sourceId,
56-
},
57-
)
58-
} else {
59-
identities.push({
60-
platform: PlatformType.TNC,
61-
value: email,
62-
type: MemberIdentityType.USERNAME,
63-
verified: true,
64-
verifiedBy: PlatformType.TNC,
65-
sourceId,
66-
})
67-
}
23+
const identities = this.buildMemberIdentities({
24+
email,
25+
sourceId,
26+
platformUsername: null,
27+
lfUsername,
28+
})
6829

6930
const activity: IActivityData = {
7031
type: TncActivityType.ISSUED_CERTIFICATION,

services/apps/snowflake_connectors/src/integrations/tnc/courses/transformer.ts

Lines changed: 8 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
import { TNC_GRID, TncActivityType } from '@crowd/integrations'
22
import { getServiceChildLogger } from '@crowd/logging'
3-
import {
4-
IActivityData,
5-
IMemberData,
6-
MemberAttributeName,
7-
MemberIdentityType,
8-
PlatformType,
9-
} from '@crowd/types'
3+
import { IActivityData, MemberAttributeName, PlatformType } from '@crowd/types'
104

115
import { TransformedActivity } from '../../../core/transformerBase'
126
import { TncTransformerBase } from '../tncTransformerBase'
@@ -32,47 +26,14 @@ export class TncCoursesTransformer extends TncTransformerBase {
3226

3327
const learnerName = (row.LEARNER_NAME as string | null)?.trim() || null
3428
const lfUsername = (row.LFID as string | null)?.trim() || null
29+
const sourceId = (row.INTERNAL_TI_USER_ID as string | null) || undefined
3530

36-
const identities: IMemberData['identities'] = []
37-
const sourceId = undefined
38-
39-
if (lfUsername) {
40-
identities.push(
41-
{
42-
platform: PlatformType.TNC,
43-
value: email,
44-
type: MemberIdentityType.EMAIL,
45-
verified: true,
46-
verifiedBy: PlatformType.TNC,
47-
sourceId,
48-
},
49-
{
50-
platform: PlatformType.TNC,
51-
value: lfUsername,
52-
type: MemberIdentityType.USERNAME,
53-
verified: true,
54-
verifiedBy: PlatformType.TNC,
55-
sourceId,
56-
},
57-
{
58-
platform: PlatformType.LFID,
59-
value: lfUsername,
60-
type: MemberIdentityType.USERNAME,
61-
verified: true,
62-
verifiedBy: PlatformType.TNC,
63-
sourceId,
64-
},
65-
)
66-
} else {
67-
identities.push({
68-
platform: PlatformType.TNC,
69-
value: email,
70-
type: MemberIdentityType.USERNAME,
71-
verified: true,
72-
verifiedBy: PlatformType.TNC,
73-
sourceId,
74-
})
75-
}
31+
const identities = this.buildMemberIdentities({
32+
email,
33+
sourceId,
34+
platformUsername: null,
35+
lfUsername,
36+
})
7637

7738
const productType = (row.PRODUCT_TYPE as string | null)?.trim() || null
7839

0 commit comments

Comments
 (0)