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
11 changes: 11 additions & 0 deletions .changeset/sdk-preference-format-types.md
Original file line number Diff line number Diff line change
@@ -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)
48 changes: 48 additions & 0 deletions packages/sdk/src/preference-management/codecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,34 @@ export const FileMetadataState = t.intersection([
/** Override type */
export type FileMetadataState = t.TypeOf<typeof FileMetadataState>;

/**
* 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<typeof FileFormatState>;

/**
* This is the type of the receipts that are stored in the file
* that is used to track the state of the upload process.
Expand Down Expand Up @@ -250,6 +278,26 @@ export const PreferenceState = t.type({
/** Override type */
export type PreferenceState = t.TypeOf<typeof PreferenceState>;

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<typeof RequestUploadReceipts>;

export const DeletePreferenceRecordsInput = t.type({
/** Array of consent preference records to delete */
records: t.array(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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<string, string>;
/** 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' }),
);
}
Original file line number Diff line number Diff line change
@@ -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<string, string>;
/** 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]!,
}));
}
3 changes: 3 additions & 0 deletions packages/sdk/src/preference-management/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,3 +21,4 @@ export * from './discoverConsentWindow.js';
export * from './fetchConsentPreferences.js';
export * from './getPreferencesForIdentifiers.js';
export * from './fetchConsentPreferencesChunked.js';
export * from './loadReferenceData.js';
34 changes: 34 additions & 0 deletions packages/sdk/src/preference-management/loadReferenceData.ts
Original file line number Diff line number Diff line change
@@ -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<PreferenceUploadReferenceData> {
const [purposes, preferenceTopics, identifiers] = await Promise.all([
fetchAllPurposes(client, { logger }),
fetchAllPreferenceTopics(client, { logger }),
fetchAllIdentifiers(client, { logger }),
]);
return { purposes, preferenceTopics, identifiers };
}
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading
Loading