From b4c9bc9ff61db3346eeaae2bf6ede64f3cfa50c3 Mon Sep 17 00:00:00 2001 From: Michael Farrell Date: Mon, 30 Mar 2026 18:32:38 -0700 Subject: [PATCH 1/2] Add preference upload types and helpers to SDK - FileFormatState codec (schema-only CSV column mapping) - RequestUploadReceipts codec (upload progress tracking) - loadReferenceData (fetches purposes, topics, identifiers in parallel) - getPreferenceIdentifiersFromRow + getUniquePreferenceIdentifierNamesFromRow - Tests for both identifier helpers Made-with: Cursor --- .changeset/sdk-preference-format-types.md | 11 ++ .../sdk/src/preference-management/codecs.ts | 48 +++++++ .../getPreferenceIdentifiersFromRow.ts | 34 +++++ ...tUniquePreferenceIdentifierNamesFromRow.ts | 37 ++++++ .../sdk/src/preference-management/index.ts | 3 + .../loadReferenceData.ts | 34 +++++ .../getPreferenceIdentifiersFromRow.test.ts | 100 +++++++++++++++ ...uePreferenceIdentifierNamesFromRow.test.ts | 121 ++++++++++++++++++ 8 files changed, 388 insertions(+) create mode 100644 .changeset/sdk-preference-format-types.md create mode 100644 packages/sdk/src/preference-management/getPreferenceIdentifiersFromRow.ts create mode 100644 packages/sdk/src/preference-management/getUniquePreferenceIdentifierNamesFromRow.ts create mode 100644 packages/sdk/src/preference-management/loadReferenceData.ts create mode 100644 packages/sdk/src/preference-management/tests/getPreferenceIdentifiersFromRow.test.ts create mode 100644 packages/sdk/src/preference-management/tests/getUniquePreferenceIdentifierNamesFromRow.test.ts diff --git a/.changeset/sdk-preference-format-types.md b/.changeset/sdk-preference-format-types.md new file mode 100644 index 00000000..209f9060 --- /dev/null +++ b/.changeset/sdk-preference-format-types.md @@ -0,0 +1,11 @@ +--- +'@transcend-io/sdk': minor +--- + +Add preference upload types and helpers to SDK + +- Add FileFormatState codec (schema-only CSV column mapping without upload receipts) +- Add RequestUploadReceipts codec (tracks upload progress and results) +- Add loadReferenceData helper (fetches purposes, topics, identifiers in parallel) +- Add getPreferenceIdentifiersFromRow helper (extracts identifiers from a CSV row) +- Add getUniquePreferenceIdentifierNamesFromRow helper (extracts unique identifiers from a CSV row) diff --git a/packages/sdk/src/preference-management/codecs.ts b/packages/sdk/src/preference-management/codecs.ts index 2a61a491..761eeabc 100644 --- a/packages/sdk/src/preference-management/codecs.ts +++ b/packages/sdk/src/preference-management/codecs.ts @@ -129,6 +129,34 @@ export const FileMetadataState = t.intersection([ /** Override type */ export type FileMetadataState = t.TypeOf; +/** + * Schema-only state for a preference CSV file format. + * + * Unlike FileMetadataState this does NOT embed upload receipts — it only + * describes how columns map to identifiers, purposes, timestamps, and metadata. + */ +export const FileFormatState = t.intersection([ + t.type({ + /** Maps each CSV column to its purpose/preference definition in Transcend */ + columnToPurposeName: ColumnPurposeMap, + /** ISO 8601 timestamp of when this config was last generated or refreshed */ + lastFetchedAt: t.string, + /** Maps each CSV column to the identifier it represents (e.g. email, userId) */ + columnToIdentifier: ColumnIdentifierMap, + }), + t.partial({ + /** CSV column whose values contain the consent timestamp */ + timestampColumn: t.string, + /** Maps CSV columns to metadata keys stored alongside the preference record */ + columnToMetadata: ColumnMetadataMap, + /** CSV columns that should be skipped during upload */ + columnsToIgnore: t.array(t.string), + }), +]); + +/** Override type */ +export type FileFormatState = t.TypeOf; + /** * This is the type of the receipts that are stored in the file * that is used to track the state of the upload process. @@ -250,6 +278,26 @@ export const PreferenceState = t.type({ /** Override type */ export type PreferenceState = t.TypeOf; +export const RequestUploadReceipts = t.type({ + /** ISO 8601 timestamp of when the receipt file was last written */ + lastFetchedAt: t.string, + /** Updates that can be applied without conflicting with existing preferences */ + pendingSafeUpdates: PendingSafePreferenceUpdates, + /** Updates that conflict with existing preference values and need review */ + pendingConflictUpdates: PendingWithConflictPreferenceUpdates, + /** Rows skipped because their preferences already match the store */ + skippedUpdates: SkippedPreferenceUpdates, + /** Updates that were attempted but failed with an API error */ + failingUpdates: FailingPreferenceUpdates, + /** Updates still queued to be sent to the API */ + pendingUpdates: PreferenceUpdateMap, + /** Updates that have been successfully written to the preference store */ + successfulUpdates: PreferenceUpdateMap, +}); + +/** Override type */ +export type RequestUploadReceipts = t.TypeOf; + export const DeletePreferenceRecordsInput = t.type({ /** Array of consent preference records to delete */ records: t.array( diff --git a/packages/sdk/src/preference-management/getPreferenceIdentifiersFromRow.ts b/packages/sdk/src/preference-management/getPreferenceIdentifiersFromRow.ts new file mode 100644 index 00000000..21286f01 --- /dev/null +++ b/packages/sdk/src/preference-management/getPreferenceIdentifiersFromRow.ts @@ -0,0 +1,34 @@ +import type { PreferenceStoreIdentifier } from '@transcend-io/privacy-types'; + +import type { FileFormatState } from './codecs.js'; + +/** + * Extract preference store identifiers from a CSV row based on the column-to-identifier mapping. + * + * @param options - Options + * @returns Array of identifiers for the preference store API + */ +export function getPreferenceIdentifiersFromRow({ + row, + columnToIdentifier, +}: { + /** The current row from CSV file */ + row: Record; + /** The current file metadata state */ + columnToIdentifier: FileFormatState['columnToIdentifier']; +}): PreferenceStoreIdentifier[] { + const identifiers = Object.entries(columnToIdentifier) + .filter(([col]) => !!row[col]) + .map(([col, identifierMapping]) => ({ + name: identifierMapping.name, + value: row[col]!, + })); + return identifiers.sort( + (a, b) => + (a.name === 'email' ? -1 : 0) - (b.name === 'email' ? -1 : 0) || + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }), + ); +} + +/** Sentinel value indicating no timestamp/format column was selected */ +export const NONE_PREFERENCE_MAP = '[NONE]'; diff --git a/packages/sdk/src/preference-management/getUniquePreferenceIdentifierNamesFromRow.ts b/packages/sdk/src/preference-management/getUniquePreferenceIdentifierNamesFromRow.ts new file mode 100644 index 00000000..01107d0c --- /dev/null +++ b/packages/sdk/src/preference-management/getUniquePreferenceIdentifierNamesFromRow.ts @@ -0,0 +1,37 @@ +import type { FileFormatState, IdentifierMetadataForPreference } from './codecs.js'; + +/** + * Helper function to get unique identifier name present in a row + * + * @param options - Options + * @param options.row - The current row from CSV file + * @param options.columnToIdentifier - The column to identifier mapping metadata + * @returns The unique identifier names present in the row + */ +export function getUniquePreferenceIdentifierNamesFromRow({ + row, + columnToIdentifier, +}: { + /** The current row from CSV file */ + row: Record; + /** The current file metadata state */ + columnToIdentifier: FileFormatState['columnToIdentifier']; +}): (IdentifierMetadataForPreference & { + /** Column name */ + columnName: string; + /** Value of the identifier in the row */ + value: string; +})[] { + return Object.entries(columnToIdentifier) + .sort( + ([, a], [, b]) => + (a.name === 'email' ? -1 : 0) - (b.name === 'email' ? -1 : 0) || + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }), + ) + .filter(([col]) => row[col] && columnToIdentifier[col]!.isUniqueOnPreferenceStore) + .map(([col, identifier]) => ({ + ...identifier, + columnName: col, + value: row[col]!, + })); +} diff --git a/packages/sdk/src/preference-management/index.ts b/packages/sdk/src/preference-management/index.ts index e8ab78f7..3de9a53d 100644 --- a/packages/sdk/src/preference-management/index.ts +++ b/packages/sdk/src/preference-management/index.ts @@ -5,6 +5,8 @@ export * from './createPreferenceAccessTokens.js'; export * from './types.js'; export * from './codecs.js'; export * from './getPreferenceMetadataFromRow.js'; +export * from './getPreferenceIdentifiersFromRow.js'; +export * from './getUniquePreferenceIdentifierNamesFromRow.js'; export * from './getPreferenceUpdatesFromRow.js'; export * from './checkIfPendingPreferenceUpdatesAreNoOp.js'; export * from './checkIfPendingPreferenceUpdatesCauseConflict.js'; @@ -19,3 +21,4 @@ export * from './discoverConsentWindow.js'; export * from './fetchConsentPreferences.js'; export * from './getPreferencesForIdentifiers.js'; export * from './fetchConsentPreferencesChunked.js'; +export * from './loadReferenceData.js'; diff --git a/packages/sdk/src/preference-management/loadReferenceData.ts b/packages/sdk/src/preference-management/loadReferenceData.ts new file mode 100644 index 00000000..0fac506e --- /dev/null +++ b/packages/sdk/src/preference-management/loadReferenceData.ts @@ -0,0 +1,34 @@ +import type { Logger } from '@transcend-io/utils'; +import type { GraphQLClient } from 'graphql-request'; + +import { fetchAllIdentifiers, type Identifier } from '../data-inventory/fetchAllIdentifiers.js'; +import { fetchAllPreferenceTopics, type PreferenceTopic } from './fetchAllPreferenceTopics.js'; +import { fetchAllPurposes, type Purpose } from './fetchAllPurposes.js'; + +export interface PreferenceUploadReferenceData { + /** List of purposes in the organization */ + purposes: Purpose[]; + /** List of preference topics in the organization */ + preferenceTopics: PreferenceTopic[]; + /** List of identifiers in the organization */ + identifiers: Identifier[]; +} + +/** + * Load all required reference data for an upload run. + * + * @param client - GraphQL client + * @param options - Options + * @returns Reference data arrays + */ +export async function loadReferenceData( + client: GraphQLClient, + { logger }: { logger: Logger }, +): Promise { + const [purposes, preferenceTopics, identifiers] = await Promise.all([ + fetchAllPurposes(client, { logger }), + fetchAllPreferenceTopics(client, { logger }), + fetchAllIdentifiers(client, { logger }), + ]); + return { purposes, preferenceTopics, identifiers }; +} diff --git a/packages/sdk/src/preference-management/tests/getPreferenceIdentifiersFromRow.test.ts b/packages/sdk/src/preference-management/tests/getPreferenceIdentifiersFromRow.test.ts new file mode 100644 index 00000000..ce94971d --- /dev/null +++ b/packages/sdk/src/preference-management/tests/getPreferenceIdentifiersFromRow.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; + +import type { FileFormatState } from '../codecs.js'; +import { getPreferenceIdentifiersFromRow } from '../getPreferenceIdentifiersFromRow.js'; + +const columnToIdentifier: FileFormatState['columnToIdentifier'] = { + email_col: { name: 'email', isUniqueOnPreferenceStore: true }, + user_id_col: { name: 'userId', isUniqueOnPreferenceStore: true }, + phone_col: { name: 'phone', isUniqueOnPreferenceStore: false }, +}; + +describe('getPreferenceIdentifiersFromRow', () => { + it('extracts identifiers for all mapped columns with values', () => { + const result = getPreferenceIdentifiersFromRow({ + row: { + email_col: 'alice@example.com', + user_id_col: 'u-123', + phone_col: '+15551234567', + }, + columnToIdentifier, + }); + + expect(result).toEqual([ + { name: 'email', value: 'alice@example.com' }, + { name: 'phone', value: '+15551234567' }, + { name: 'userId', value: 'u-123' }, + ]); + }); + + it('sorts email to the front', () => { + const result = getPreferenceIdentifiersFromRow({ + row: { + user_id_col: 'u-1', + email_col: 'bob@example.com', + }, + columnToIdentifier, + }); + + expect(result[0]).toEqual({ name: 'email', value: 'bob@example.com' }); + expect(result[1]).toEqual({ name: 'userId', value: 'u-1' }); + }); + + it('sorts non-email identifiers alphabetically', () => { + const mapping: FileFormatState['columnToIdentifier'] = { + col_z: { name: 'zeta', isUniqueOnPreferenceStore: false }, + col_a: { name: 'alpha', isUniqueOnPreferenceStore: false }, + col_m: { name: 'mike', isUniqueOnPreferenceStore: false }, + }; + + const result = getPreferenceIdentifiersFromRow({ + row: { col_z: 'z', col_a: 'a', col_m: 'm' }, + columnToIdentifier: mapping, + }); + + expect(result.map((r) => r.name)).toEqual(['alpha', 'mike', 'zeta']); + }); + + it('skips columns with empty string values', () => { + const result = getPreferenceIdentifiersFromRow({ + row: { + email_col: 'alice@example.com', + user_id_col: '', + phone_col: '+15551234567', + }, + columnToIdentifier, + }); + + expect(result).toEqual([ + { name: 'email', value: 'alice@example.com' }, + { name: 'phone', value: '+15551234567' }, + ]); + }); + + it('skips columns missing from the row', () => { + const result = getPreferenceIdentifiersFromRow({ + row: { email_col: 'alice@example.com' }, + columnToIdentifier, + }); + + expect(result).toEqual([{ name: 'email', value: 'alice@example.com' }]); + }); + + it('returns empty array when no columns match', () => { + const result = getPreferenceIdentifiersFromRow({ + row: { unrelated: 'value' }, + columnToIdentifier, + }); + + expect(result).toEqual([]); + }); + + it('returns empty array for empty columnToIdentifier', () => { + const result = getPreferenceIdentifiersFromRow({ + row: { email_col: 'alice@example.com' }, + columnToIdentifier: {}, + }); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/sdk/src/preference-management/tests/getUniquePreferenceIdentifierNamesFromRow.test.ts b/packages/sdk/src/preference-management/tests/getUniquePreferenceIdentifierNamesFromRow.test.ts new file mode 100644 index 00000000..4aa86f50 --- /dev/null +++ b/packages/sdk/src/preference-management/tests/getUniquePreferenceIdentifierNamesFromRow.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; + +import type { FileFormatState } from '../codecs.js'; +import { getUniquePreferenceIdentifierNamesFromRow } from '../getUniquePreferenceIdentifierNamesFromRow.js'; + +const columnToIdentifier: FileFormatState['columnToIdentifier'] = { + email_col: { name: 'email', isUniqueOnPreferenceStore: true }, + user_id_col: { name: 'userId', isUniqueOnPreferenceStore: true }, + phone_col: { name: 'phone', isUniqueOnPreferenceStore: false }, +}; + +describe('getUniquePreferenceIdentifierNamesFromRow', () => { + it('returns only unique identifiers that have values', () => { + const result = getUniquePreferenceIdentifierNamesFromRow({ + row: { + email_col: 'alice@example.com', + user_id_col: 'u-123', + phone_col: '+15551234567', + }, + columnToIdentifier, + }); + + expect(result).toEqual([ + { + name: 'email', + isUniqueOnPreferenceStore: true, + columnName: 'email_col', + value: 'alice@example.com', + }, + { + name: 'userId', + isUniqueOnPreferenceStore: true, + columnName: 'user_id_col', + value: 'u-123', + }, + ]); + }); + + it('excludes non-unique identifiers', () => { + const result = getUniquePreferenceIdentifierNamesFromRow({ + row: { phone_col: '+15551234567' }, + columnToIdentifier, + }); + + expect(result).toEqual([]); + }); + + it('sorts email to the front', () => { + const result = getUniquePreferenceIdentifierNamesFromRow({ + row: { + user_id_col: 'u-1', + email_col: 'bob@example.com', + }, + columnToIdentifier, + }); + + expect(result[0]!.name).toBe('email'); + expect(result[1]!.name).toBe('userId'); + }); + + it('sorts non-email unique identifiers alphabetically', () => { + const mapping: FileFormatState['columnToIdentifier'] = { + col_z: { name: 'zeta', isUniqueOnPreferenceStore: true }, + col_a: { name: 'alpha', isUniqueOnPreferenceStore: true }, + }; + + const result = getUniquePreferenceIdentifierNamesFromRow({ + row: { col_z: 'z', col_a: 'a' }, + columnToIdentifier: mapping, + }); + + expect(result.map((r) => r.name)).toEqual(['alpha', 'zeta']); + }); + + it('skips columns with empty string values', () => { + const result = getUniquePreferenceIdentifierNamesFromRow({ + row: { + email_col: 'alice@example.com', + user_id_col: '', + }, + columnToIdentifier, + }); + + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('email'); + }); + + it('skips columns missing from the row', () => { + const result = getUniquePreferenceIdentifierNamesFromRow({ + row: { email_col: 'alice@example.com' }, + columnToIdentifier, + }); + + expect(result).toEqual([ + { + name: 'email', + isUniqueOnPreferenceStore: true, + columnName: 'email_col', + value: 'alice@example.com', + }, + ]); + }); + + it('returns empty array when no columns match', () => { + const result = getUniquePreferenceIdentifierNamesFromRow({ + row: { unrelated: 'value' }, + columnToIdentifier, + }); + + expect(result).toEqual([]); + }); + + it('returns empty array for empty columnToIdentifier', () => { + const result = getUniquePreferenceIdentifierNamesFromRow({ + row: { email_col: 'alice@example.com' }, + columnToIdentifier: {}, + }); + + expect(result).toEqual([]); + }); +}); From a5f87354285d594365596f04f5b362c8a5a2cab9 Mon Sep 17 00:00:00 2001 From: Michael Farrell Date: Mon, 30 Mar 2026 18:53:22 -0700 Subject: [PATCH 2/2] Remove misplaced NONE_PREFERENCE_MAP from SDK getPreferenceIdentifiersFromRow Made-with: Cursor --- .../preference-management/getPreferenceIdentifiersFromRow.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/sdk/src/preference-management/getPreferenceIdentifiersFromRow.ts b/packages/sdk/src/preference-management/getPreferenceIdentifiersFromRow.ts index 21286f01..ea2d3331 100644 --- a/packages/sdk/src/preference-management/getPreferenceIdentifiersFromRow.ts +++ b/packages/sdk/src/preference-management/getPreferenceIdentifiersFromRow.ts @@ -29,6 +29,3 @@ export function getPreferenceIdentifiersFromRow({ a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }), ); } - -/** Sentinel value indicating no timestamp/format column was selected */ -export const NONE_PREFERENCE_MAP = '[NONE]';