Skip to content
2 changes: 2 additions & 0 deletions config/example.env
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ SEGMENT_API_KEY=FAKE_API_KEY
ORTTO_API_KEY=FAKE_API_KEY
QACC_ORTTO_API_KEY=FAKE_API_KEY
ORTTO_ACTIVITY_API=https://api-us.ortto.app/v1/activities/create
# Finite timeout (ms) for the Ortto activity request; defaults to 10000 if unset/invalid.
ORTTO_REQUEST_TIMEOUT_MS=10000

#JWT_AUTHORIZATION_ADAPTER=siweMicroservice
JWT_AUTHORIZATION_ADAPTER=mock
Expand Down
41 changes: 41 additions & 0 deletions migrations/1732000000000-seedNotificationTypeSyncOrttoContact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import {
NOTIFICATION_CATEGORY,
NOTIFICATION_TYPE_NAMES,
} from '../src/types/general';
import { MICRO_SERVICES } from '../src/utils/utils';
import {
NotificationType,
SCHEMA_VALIDATORS_NAMES,
} from '../src/entities/notificationType';

// giveth-v6-core#426: the v6 → Ortto contact sync event. ORTTO-category so it
// sends no email and needs no wallet/notification-settings — sendNotification
// just upserts the Ortto person. Must be seeded for the `givethio` microservice
// or v6-core's requests 400 with INVALID_NOTIFICATION_TYPE.
const SyncOrttoContactNotificationType = [
{
name: NOTIFICATION_TYPE_NAMES.SYNC_ORTTO_CONTACT,
description: NOTIFICATION_TYPE_NAMES.SYNC_ORTTO_CONTACT,
microService: MICRO_SERVICES.givethio,
category: NOTIFICATION_CATEGORY.ORTTO,
schemaValidator: SCHEMA_VALIDATORS_NAMES.SYNC_ORTTO_CONTACT,
},
];

export class seedNotificationTypeSyncOrttoContact1732000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.manager.save(
NotificationType,
SyncOrttoContactNotificationType,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM notification_type WHERE "name" = 'Sync Ortto contact';`,
);
}
}
33 changes: 30 additions & 3 deletions src/adapters/emailAdapter/orttoAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,20 @@ import { logger } from '../../utils/logger';
import { OrttoAdapterInterface } from './orttoAdapterInterface';
import { MICRO_SERVICES } from '../../utils/utils';

// Finite timeout so a stalled Ortto connection rejects promptly instead of
// keeping the notification-center request — and, for the contact sync, its 502
// retry path — pending indefinitely. Sourced from ORTTO_REQUEST_TIMEOUT_MS with
// a validated fallback so a missing/garbage value never disables the timeout.
const DEFAULT_ORTTO_TIMEOUT_MS = 10_000;
const resolveOrttoTimeoutMs = (): number => {
const parsed = Number(process.env.ORTTO_REQUEST_TIMEOUT_MS);
return Number.isFinite(parsed) && parsed > 0
? parsed
: DEFAULT_ORTTO_TIMEOUT_MS;
};

export class OrttoAdapter implements OrttoAdapterInterface {
async callOrttoActivity(data: any, microService: string): Promise<void> {
async callOrttoActivity(data: any, microService: string): Promise<boolean> {
try {
if (!data) {
throw new Error('callOrttoActivity input data is empty');
Expand All @@ -17,6 +29,7 @@ export class OrttoAdapter implements OrttoAdapterInterface {
method: 'post',
maxBodyLength: Infinity,
url: process.env.ORTTO_ACTIVITY_API,
timeout: resolveOrttoTimeoutMs(),
headers: {
'X-Api-Key': apiKey as string,
'Content-Type': 'application/json',
Expand All @@ -25,11 +38,25 @@ export class OrttoAdapter implements OrttoAdapterInterface {
};
data.activities.map((a: any) => logger.debug('orttoActivityCall', a));
await axios.request(config);
return true;
} catch (e) {
// Log only a sanitized summary. NEVER log `data` (it carries the contact's
// email / names / v6-user-id) or the raw Axios error (its `config` holds
// the `X-Api-Key` header and the request body). Activity ids and the HTTP
// status are safe, non-sensitive identifiers that are enough to debug.
const activityIds = Array.isArray(data?.activities)
? data.activities.map((a: any) => a?.activity_id)
: [];
logger.error('orttoActivityCall error', {
error: e,
data,
microService,
activityIds,
status: axios.isAxiosError(e) ? e.response?.status : undefined,
message: e instanceof Error ? e.message : String(e),
});
// Report failure (do not throw) so callers that need a confirmed upsert
// can react; existing fire-and-forget callers ignore the return value and
// keep their previous swallow-and-continue behavior.
return false;
}
}
}
6 changes: 5 additions & 1 deletion src/adapters/emailAdapter/orttoAdapterInterface.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export interface OrttoAdapterInterface {
callOrttoActivity(data: any, microService: string): Promise<void>;
// Resolves `true` when Ortto accepted the activity/merge, `false` when the
// Ortto call failed (error is logged, not thrown). Callers that need a
// confirmed upsert (e.g. the giveth-v6-core#426 contact sync) branch on this
// instead of assuming success from a resolved promise.
callOrttoActivity(data: any, microService: string): Promise<boolean>;
}
3 changes: 2 additions & 1 deletion src/adapters/emailAdapter/orttoMockAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { logger } from '../../utils/logger';
import { OrttoAdapterInterface } from './orttoAdapterInterface';

export class OrttoMockAdapter implements OrttoAdapterInterface {
async callOrttoActivity(data: any, microService: string): Promise<void> {
async callOrttoActivity(data: any, microService: string): Promise<boolean> {
logger.debug('OrttoMockAdapter has been called', data, microService);
return true;
}
}
1 change: 1 addition & 0 deletions src/entities/notificationType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const SCHEMA_VALIDATORS_NAMES = {
SEND_EMAIL_CONFIRMATION: 'sendEmailConfirmation',
SEND_USER_EMAIL_CONFIRMATION_CODE_FLOW: 'sendUserEmailConfirmationCodeFlow',
CREATE_ORTTO_PROFILE: 'createOrttoProfile',
SYNC_ORTTO_CONTACT: 'syncOrttoContact',
SUBSCRIBE_ONBOARDING: 'subscribeOnboarding',
SUPERFLUID: 'userSuperTokensCritical',
ADMIN_MESSAGE: 'adminMessage',
Expand Down
75 changes: 75 additions & 0 deletions src/services/notificationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,79 @@ describe('activityCreator', () => {
}),
);
});

// giveth-v6-core#426 — the contact sync's cross-layer contract with v6-core.
it('builds the SYNC_ORTTO_CONTACT activity: dedicated inert activity, merges on the v6 user id, stamps the sourced-from-v6 marker', () => {
const payload = {
email: 'contact@example.com',
userId: 42,
firstName: 'Ada',
lastName: 'Lovelace',
};
const result = activityCreator(
payload,
NOTIFICATIONS_EVENT_NAMES.SYNC_ORTTO_CONTACT,
MICRO_SERVICES.givethio,
);
expect(result).to.deep.equal({
activities: [
{
activity_id: 'act:cm:sync-ortto-contact',
attributes: {
'str:cm:email': 'contact@example.com',
'str:cm:firstname': 'Ada',
'str:cm:lastname': 'Lovelace',
'str:cm:v6-user-id': '42',
},
fields: {
'str::email': 'contact@example.com',
'str:cm:v6-user-id': '42',
'bol:cm:sourced-from-v6': true,
},
},
],
merge_by: ['str:cm:v6-user-id'],
});
});

it('merges the SYNC_ORTTO_CONTACT person on the v6 user id regardless of ENVIRONMENT (AC4 on staging)', () => {
const original = process.env.ENVIRONMENT;
process.env.ENVIRONMENT = 'production';
try {
const result = activityCreator(
{ email: 'contact@example.com', userId: 7 },
NOTIFICATIONS_EVENT_NAMES.SYNC_ORTTO_CONTACT,
MICRO_SERVICES.givethio,
);
// Never merges by email (that would create a duplicate on re-point), and
// never falls through to the generic prod block's 'str:cm:user-id'.
expect(result.merge_by).to.deep.equal(['str:cm:v6-user-id']);
expect(result.activities[0].fields).to.deep.equal({
'str::email': 'contact@example.com',
'str:cm:v6-user-id': '7',
'bol:cm:sourced-from-v6': true,
});
} finally {
process.env.ENVIRONMENT = original;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it('omits optional names for the SYNC_ORTTO_CONTACT activity (nameless wallet/Turnkey profiles still sync)', () => {
const result = activityCreator(
{ email: 'contact@example.com', userId: 99 },
NOTIFICATIONS_EVENT_NAMES.SYNC_ORTTO_CONTACT,
MICRO_SERVICES.givethio,
);
// The merge key, email, and marker survive even with no names supplied.
expect(result.activities[0].activity_id).to.equal(
'act:cm:sync-ortto-contact',
);
expect(result.merge_by).to.deep.equal(['str:cm:v6-user-id']);
expect(result.activities[0].fields).to.deep.equal({
'str::email': 'contact@example.com',
'str:cm:v6-user-id': '99',
'bol:cm:sourced-from-v6': true,
});
expect(result.activities[0].attributes['str:cm:v6-user-id']).to.equal('99');
});
Comment thread
ae2079 marked this conversation as resolved.
Outdated
});
49 changes: 48 additions & 1 deletion src/services/notificationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ export const activityCreator = (
'str:cm:userid': payload.userId?.toString(),
};
break;
case NOTIFICATIONS_EVENT_NAMES.SYNC_ORTTO_CONTACT:
attributes = {
'str:cm:email': payload.email,
'str:cm:firstname': payload.firstName,
'str:cm:lastname': payload.lastName,
'str:cm:v6-user-id': payload.userId?.toString(),
};
break;
case NOTIFICATIONS_EVENT_NAMES.SUPER_TOKENS_BALANCE_DEPLETED:
attributes = {
'str:cm:tokensymbol': payload.tokenSymbol,
Expand Down Expand Up @@ -215,6 +223,27 @@ export const activityCreator = (
logger.debug('activityCreator() invalid ORTTO_EVENT_NAMES', orttoEventName);
return;
}
// giveth-v6-core#426: the v6 contact sync ALWAYS merges on the stable v6 user
// id (unlike the generic block below, which only does so in production), so a
// canonical-email change re-points the SAME Ortto person instead of creating
// a duplicate. It also stamps the durable `bol:cm:sourced-from-v6` marker so
// v6-managed contacts stay distinguishable from legacy v5-sourced ones.
if (orttoEventName === NOTIFICATIONS_EVENT_NAMES.SYNC_ORTTO_CONTACT) {
return {
activities: [
{
activity_id: `act:cm:${ORTTO_EVENT_NAMES[orttoEventName]}`,
attributes,
fields: {
'str::email': payload.email,
'str:cm:v6-user-id': payload.userId?.toString(),
'bol:cm:sourced-from-v6': true,
},
},
],
merge_by: ['str:cm:v6-user-id'],
};
}
const fields = {
'str::email': payload.email,
};
Expand Down Expand Up @@ -327,7 +356,25 @@ export const sendNotification = async (
microService,
);
if (data) {
await getEmailAdapter().callOrttoActivity(data, microService);
const orttoSucceeded = await getEmailAdapter().callOrttoActivity(
data,
microService,
);
// giveth-v6-core#426: the contact-sync event needs a CONFIRMED upsert —
// v6-core advances its per-user sync marker only on a 2xx, and relies on
// its reconcile cron to retry otherwise. Surface an Ortto-side failure as
// a 502 for this event so the caller does not record a false success and
// silently stop retrying. Other Ortto events keep their fire-and-forget
// behavior (the boolean is ignored).
if (
!orttoSucceeded &&
body.eventName === NOTIFICATIONS_EVENT_NAMES.SYNC_ORTTO_CONTACT
) {
throw new StandardError({
message: errorMessages.ORTTO_CONTACT_SYNC_FAILED,
httpStatusCode: 502,
});
}
}
emailStatus = EMAIL_STATUSES.SENT;
}
Expand Down
1 change: 1 addition & 0 deletions src/types/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export enum NOTIFICATION_TYPE_NAMES {
YOUR_PROJECT_GOT_A_RANK = 'Your project got a rank',
SUBSCRIBE_ONBOARDING = 'Subscribe onboarding',
CREATE_ORTTO_PROFILE = 'Create Ortto profile',
SYNC_ORTTO_CONTACT = 'Sync Ortto contact',

NOTIFY_REWARD_AMOUNT = 'Notify reward amount',
}
12 changes: 12 additions & 0 deletions src/types/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export enum NOTIFICATIONS_EVENT_NAMES {
SUPER_TOKENS_BALANCE_MONTH = 'One month left in stream balance',
SUPER_TOKENS_BALANCE_DEPLETED = 'Stream balance depleted',
CREATE_ORTTO_PROFILE = 'Create Ortto profile',
// v6 → Ortto contact sync (giveth-v6-core#426). Distinct from
// CREATE_ORTTO_PROFILE: it always merges on the stable v6 user id (so a
// canonical-email change re-points the same person instead of duplicating)
// and stamps a durable "sourced from v6" marker.
SYNC_ORTTO_CONTACT = 'Sync Ortto contact',
SEND_EMAIL_CONFIRMATION = 'Send email confirmation',
SEND_USER_EMAIL_CONFIRMATION_CODE_FLOW = 'Send email confirmation code flow',
SUBSCRIBE_ONBOARDING = 'Subscribe onboarding',
Expand Down Expand Up @@ -78,6 +83,13 @@ export const ORTTO_EVENT_NAMES = {
[NOTIFICATIONS_EVENT_NAMES.PROJECT_BADGE_REVOKE_LAST_WARNING]:
'second-update-warning',
[NOTIFICATIONS_EVENT_NAMES.CREATE_ORTTO_PROFILE]: 'created-profile',
// DEDICATED, inert activity (giveth-v6-core#426). We intentionally do NOT
// reuse 'created-profile': the contact sync must be side-effect-free (it
// creates/updates a contact but sends no email), and it fires again on every
// canonical-email re-point, so it must not be bound to any Ortto journey that
// could send a (duplicate) welcome email. This activity must exist in the
// Ortto workspace with NO automation bound to it.
[NOTIFICATIONS_EVENT_NAMES.SYNC_ORTTO_CONTACT]: 'sync-ortto-contact',
[NOTIFICATIONS_EVENT_NAMES.SEND_EMAIL_CONFIRMATION]:
'verification-form-email-verification',
[NOTIFICATIONS_EVENT_NAMES.NOTIFY_REWARD_AMOUNT]: 'notify-reward',
Expand Down
1 change: 1 addition & 0 deletions src/utils/errorMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,5 @@ export const errorMessages = {
ERROR_IN_GETTING_ACCESS_TOKEN_BY_AUTHORIZATION_CODE:
'Error in getting accessToken by authorization code',
ORTTO_SPECIFIC: 'Ortto specific notification',
ORTTO_CONTACT_SYNC_FAILED: 'Failed to upsert the Ortto contact',
};
15 changes: 15 additions & 0 deletions src/utils/validators/segmentAndMetadataValidators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,17 @@ const createOrttoProfileSegmentSchema = Joi.object({
userId: Joi.number().required(),
});

// giveth-v6-core#426 contact sync. Names are optional (and may be blank):
// wallet-only / Turnkey profiles frequently have no first/last name, and they
// must still sync. `email` + `userId` are the only required keys — `userId` is
// the stable merge key the Ortto person is deduped on.
const syncOrttoContactSegmentSchema = Joi.object({
email: Joi.string().required(),
userId: Joi.number().required(),
firstName: Joi.string().allow('', null).optional(),
lastName: Joi.string().allow('', null).optional(),
});

const sendEmailConfirmationSchema = Joi.object({
email: Joi.string().required(),
verificationLink: Joi.string().required(),
Expand Down Expand Up @@ -199,6 +210,10 @@ export const SEGMENT_METADATA_SCHEMA_VALIDATOR: {
segment: createOrttoProfileSegmentSchema,
metadata: null,
},
syncOrttoContact: {
segment: syncOrttoContactSegmentSchema,
metadata: null,
},
subscribeOnboarding: {
segment: subscribeOnboardingSchema,
metadata: null,
Expand Down
Loading