Skip to content

EFIAL-458-config/dependencies and EFIAL-459-partner-sync#76

Open
rtavernaea wants to merge 57 commits into
developmentfrom
EDFIAL-458
Open

EFIAL-458-config/dependencies and EFIAL-459-partner-sync#76
rtavernaea wants to merge 57 commits into
developmentfrom
EDFIAL-458

Conversation

@rtavernaea

@rtavernaea rtavernaea commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

This PR addresses EDFIAL-458 and EDFIAL-459.

EDFIAL-458:

  • Add config for UM & Tx sync
    • Texas specific secret & TX_SYNC_CRON
      • I think both syncs could potentially end up using the same SYNC_CRON value but kept them separate for now
  • PartnerSyncCoordinator handles scheduling and kicking off each sync based on what config values are available.
    • Right now it goes through all of the possible handlers each time the app starts up. Alternatively it could check the db first for only sync handlers that are currently implemented. This might be more efficient but would mean we have to add an initial partner to register the sync method.

EDFIAL-459:

Both syncs share the same pg-boss connection

  • user_management_sync
    • Mirrors exsiting connection and transactions from Podium sync
    • No children/metatenancy for now
    • Partners not sync-managed (i.e., sync_managed: null) are left alone by the sync
  • tx_sync
    • Empty for now. Need to land on the best approach for this between options laid out in EDFIAL-460

@rtavernaea rtavernaea changed the title Edfial 458 EFIAL-458-config/dependencies and EFIAL-459-partner-sync Jun 18, 2026
@snyk-io-us

snyk-io-us Bot commented Jun 26, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@edandylytics edandylytics left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple more comments.

const url = this.get('UM_URL');
const auth0Domain = this.get('UM_AUTH0_DOMAIN');
const clientId = this.get('UM_CLIENT_ID');
const clientSecret = this.get('UM_CLIENT_SECRET');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deployed environments will need to look up credentials in an AWS secret. You can check out other methods on this service for how to use a secret and fall back to env vars if a secret doesn't exist. postgresPoolConfig is a good example.

}

for (const partner of existingPartners) {
if (!!partner.managedBy && !partner.deletedOn && !apiPartnerCodes.has(partner.id)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep -- TX being in the UM results is a clearer case than the one I mentioned. Here's how I think about it: It's Runway's job to determine which partners each sync manages. Runway shouldn't trust external systems to say which partners it can update. That means we need to ensure that it's Runway's internal state (partner.magagedBy) that scopes the sync, not what the external systems report.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should probably refactor away from this coordinator class. It looks like it does two things:

  1. Initialize PgBoss and manage its lifecycle
  2. Start up the sync

For PgBoss, I expect we'll be using that in other places in the app soon and it'd be helpful to have that as it's own injectable component that can be shared.

Once you remove the PgBoss initialization, do we need a coordinator? It seems each sync service could start itself in its own OnModuleInit. Then we don't need the getSyncConfig wrapper either.

Let me know if I'm missing something there, though.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think that makes sense, I added the pg-boss service in its own folder because I'm not sure where else it will be used

@rtavernaea rtavernaea requested a review from edandylytics June 30, 2026 21:00

@edandylytics edandylytics left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Broad strokes look good. Still spending time with it, but a few things for you to check out

Comment thread app/api/src/pg-boss/pg-boss.service.ts Outdated
host ?? 'localhost'
}:${port ?? 5432}/${database}?sslmode=${sslMode}`;

this.boss = new PgBoss(connStr);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to construct a connection string? Looks like the constructor accepts an options object that's the same shape as the pool config.

Comment thread app/api/src/pg-boss/pg-boss.service.ts Outdated

@Injectable()
export class PgBossService implements OnModuleInit, OnModuleDestroy {
boss!: PgBoss;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion that boss is populated could cause issues down the road since it hides the fact that boss is not really available until after onModuleInit runs. Based on this typing, it looks like you should be able to reference boss in a constructor. Or you might think that you can reference boss in another provider's onModuleInit and not have to figure out whether your provider's init runs before or after PgBossService's.


TX_SYNC_CRON?: string;
TX_CLIENT_SECRET?: string;
TX_CONFIG_SECRET?: string; // deployed envs: name of AWS secret containing TX credentials

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep TX stuff out of this PR

audience: string;
};

export type TxConfig = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove this TX stuff too

if (typeof secret !== 'object') {
throw new Error(`Value for AWS secret ${configSecret} must be an object`);
}
if (!syncCron) return null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not allow the caller to default a cron schedule? I expect we'll only really need an env var for local testing and if we happen to get the default wrong for some reason

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah makes sense. What makes sense as a default? Every 24 hours?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds good to me. Sometime in the early morning or a bit after UM syncs tenants from Heimdall

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SC prod syncs at 10:45 pm, went with 2 here

Comment thread app/api/src/config/app-config.service.ts
const syncCron = this.get('UM_SYNC_CRON');
const configSecret = this.get('UM_CONFIG_SECRET');
if (configSecret) {
const secret = await this.getAWSSecret(configSecret);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use fetchAWSSecret instead. getAWSSecret does some caching that I now think is unhelpful since it requires an app restart to pick up new values from the secret. I'd like to move away from getAWSSecret but haven't removed it fully.

if (configSecret) {
const secret = await this.getAWSSecret(configSecret);
if (typeof secret !== 'object') {
throw new Error(`Value for AWS secret ${configSecret} must be an object`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would happen if this were to throw?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a try catch here to log that the sync didn't get scheduled so the app won't fail to start up if this throws. Not sure if it's necessary to add in the sync() method too

@edandylytics edandylytics left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still need to spend some time with the tests and running locally. Functionally, this looks like it should work well. The Boss module updates are clean and I like how simple it is now.

The one correctness issue we need to update is checking managedBy for undeletes. I'd also like to add a managedBy filter to the DB queries as a backstop against bugs.

The remaining comments are mostly aimed at simplification: reducing the number of intermediate data structures, aligning the form of data structures with how they'll be used, removing layers of indirection. None of these are correctness issues, just ways to simplify and hopefully make the sync easier to update down the road. (And I realize much of this is inherited from Podium! Still, good to tighten up if we can)

Comment thread app/api/src/pg-boss/pg-boss.service.ts Outdated
Comment thread app/api/src/partner-sync/user-management/um-sync.handler.ts Outdated
Comment thread app/api/src/partner-sync/user-management/um-sync.handler.ts Outdated
Comment thread app/api/src/partner-sync/user-management/um-sync.handler.ts Outdated
tenantsByPartner.set(tenant.partnerId, new Map());
}
tenantsByPartner.get(tenant.partnerId)!.set(tenant.code, tenant);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if there's a way to simplify how we construct tenantsByPartner. It feels like a lot of data structure manipulation that all looks correct, but if we can be more concise I'd find it easier to to reason about.

What if

  1. We had one query on partner that does an include on tenants?
  2. Then we just do something like this to create tenantsByPartner
const tenantsByPartner = new Map(
  existingPartners.map(p => [p.id, new Map(p.tenant.map(t => [t.code, t]))])
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I've read the full sync, I'm not sure we need tenantsByPartner as it's constructed here. We use it twice:

  1. on line 224 we convert the tenant map back into an array of tenants (so might as well use partner.tenants from an include)
  2. on line 245 we do want a map of tenants, but that's easy enough to just create down there where we use it and skip the partner layer (e.g. const tenantMap = new Map(p.tenant.map(t => [t.code, t]))

Comment thread app/api/src/partner-sync/user-management/um-sync.handler.ts Outdated
Comment thread app/api/src/partner-sync/user-management/um-sync.handler.ts Outdated
this.logger.warn('UM sync config not set — skipping sync');
return;
}
await this.runSync(config);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why split sync and runSync?

I see that tests call runSync but they need to cast the service as any to invoke the private method, which is a bit of a smell. In general, I'd like tests to focus on testing the public interface rather than private internals as that allows you to use existing tests while refactoring. That consideration doesn't really apply here, but if we're testing the private method, then it's worth questioning whether it really need to be private. (Note that we can mock umConfig to pass mock config to the sync.)

Comment thread app/api/.env.copyme Outdated
Comment thread app/api/src/config/app-config.service.ts Outdated
@rtavernaea rtavernaea requested a review from edandylytics July 7, 2026 12:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants