@accounter/server
Version:
Accounter GraphQL server
635 lines (605 loc) • 25.7 kB
text/typescript
import DataLoader from 'dataloader';
import { GraphQLError } from 'graphql';
import { Injectable, Scope } from 'graphql-modules';
import { sql } from '@pgtyped/runtime';
import {
formatCurrency,
optionalDateToTimelessDateString,
resolveWriteTargetBusinessId,
} from '../../../shared/helpers/index.js';
import type { AuthContext } from '../../../shared/types/auth.js';
import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js';
import { AuthContextProvider } from '../../auth/providers/auth-context.provider.js';
import type {
AdminContext,
IGetAdminContextsQuery,
IGetAdminContextsResult,
IUpdateAdminContextParams,
IUpdateAdminContextQuery,
} from '../types.js';
// The per-role correlated subqueries below expose each admin_business_role group
// (see #3612) as an aggregated uuid[] column on the context row, so the runtime
// arrays (bank accounts, credit cards, internal wallets, dividend payments,
// VAT-excluded) are data-driven instead of enumerated from named columns/UUIDs.
const getAdminContexts = sql<IGetAdminContextsQuery>`
SELECT
uc.*,
COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = uc.owner_id AND r.role = 'BANK_ACCOUNT'), '{}'::uuid[]) AS bank_account_business_ids,
COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = uc.owner_id AND r.role = 'CREDIT_CARD'), '{}'::uuid[]) AS credit_card_business_ids,
COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = uc.owner_id AND r.role = 'CRYPTO_WALLET'), '{}'::uuid[]) AS crypto_wallet_business_ids,
COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = uc.owner_id AND r.role = 'DIVIDEND_PAYMENT'), '{}'::uuid[]) AS dividend_payment_business_ids,
COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = uc.owner_id AND r.role = 'VAT_EXCLUDED'), '{}'::uuid[]) AS vat_excluded_business_ids
FROM accounter_schema.user_context uc
WHERE uc.owner_id IN $$ownerIds;
`;
const updateAdminContext = sql<IUpdateAdminContextQuery>`
UPDATE accounter_schema.user_context
SET
default_local_currency = COALESCE(
$defaultLocalCurrency,
default_local_currency
),
default_fiat_currency_for_crypto_conversions = COALESCE(
$defaultFiatCurrencyForCryptoConversions,
default_fiat_currency_for_crypto_conversions
),
default_tax_category_id = COALESCE(
$defaultTaxCategoryId,
default_tax_category_id
),
vat_business_id = COALESCE(
$vatBusinessId,
vat_business_id
),
input_vat_tax_category_id = COALESCE(
$inputVatTaxCategoryId,
input_vat_tax_category_id
),
output_vat_tax_category_id = COALESCE(
$outputVatTaxCategoryId,
output_vat_tax_category_id
),
property_output_vat_tax_category_id = COALESCE(
$propertyOutputVatTaxCategoryId,
property_output_vat_tax_category_id
),
tax_business_id = COALESCE(
$taxBusinessId,
tax_business_id
),
tax_expenses_tax_category_id = COALESCE(
$taxExpensesTaxCategoryId,
tax_expenses_tax_category_id
),
social_security_business_id = COALESCE(
$socialSecurityBusinessId,
social_security_business_id
),
exchange_rate_tax_category_id = COALESCE(
$exchangeRateTaxCategoryId,
exchange_rate_tax_category_id
),
income_exchange_rate_tax_category_id = COALESCE(
$incomeExchangeRateTaxCategoryId,
income_exchange_rate_tax_category_id
),
exchange_rate_revaluation_tax_category_id = COALESCE(
$exchangeRateRevaluationTaxCategoryId,
exchange_rate_revaluation_tax_category_id
),
fee_tax_category_id = COALESCE(
$feeTaxCategoryId,
fee_tax_category_id
),
general_fee_tax_category_id = COALESCE(
$generalFeeTaxCategoryId,
general_fee_tax_category_id
),
fine_tax_category_id = COALESCE(
$fineTaxCategoryId,
fine_tax_category_id
),
untaxable_gifts_tax_category_id = COALESCE(
$untaxableGiftsTaxCategoryId,
untaxable_gifts_tax_category_id
),
balance_cancellation_tax_category_id = COALESCE(
$balanceCancellationTaxCategoryId,
balance_cancellation_tax_category_id
),
development_foreign_tax_category_id = COALESCE(
$developmentForeignTaxCategoryId,
development_foreign_tax_category_id
),
development_local_tax_category_id = COALESCE(
$developmentLocalTaxCategoryId,
development_local_tax_category_id
),
accumulated_depreciation_tax_category_id = COALESCE(
$accumulatedDepreciationTaxCategoryId,
accumulated_depreciation_tax_category_id
),
rnd_depreciation_expenses_tax_category_id = COALESCE(
$rndDepreciationExpensesTaxCategoryId,
rnd_depreciation_expenses_tax_category_id
),
gnm_depreciation_expenses_tax_category_id = COALESCE(
$gnmDepreciationExpensesTaxCategoryId,
gnm_depreciation_expenses_tax_category_id
),
marketing_depreciation_expenses_tax_category_id = COALESCE(
$marketingDepreciationExpensesTaxCategoryId,
marketing_depreciation_expenses_tax_category_id
),
bank_deposit_interest_income_tax_category_id = COALESCE(
$bankDepositInterestIncomeTaxCategoryId,
bank_deposit_interest_income_tax_category_id
),
business_trip_tax_category_id = COALESCE(
$businessTripTaxCategoryId,
business_trip_tax_category_id
),
business_trip_tag_id = COALESCE(
$businessTripTagId,
business_trip_tag_id
),
expenses_to_pay_tax_category_id = COALESCE(
$expensesToPayTaxCategoryId,
expenses_to_pay_tax_category_id
),
expenses_in_advance_tax_category_id = COALESCE(
$expensesInAdvanceTaxCategoryId,
expenses_in_advance_tax_category_id
),
income_to_collect_tax_category_id = COALESCE(
$incomeToCollectTaxCategoryId,
income_to_collect_tax_category_id
),
income_in_advance_tax_category_id = COALESCE(
$incomeInAdvanceTaxCategoryId,
income_in_advance_tax_category_id
),
zkufot_expenses_tax_category_id = COALESCE(
$zkufotExpensesTaxCategoryId,
zkufot_expenses_tax_category_id
),
zkufot_income_tax_category_id = COALESCE(
$zkufotIncomeTaxCategoryId,
zkufot_income_tax_category_id
),
social_security_expenses_tax_category_id = COALESCE(
$socialSecurityExpensesTaxCategoryId,
social_security_expenses_tax_category_id
),
salary_expenses_tax_category_id = COALESCE(
$salaryExpensesTaxCategoryId,
salary_expenses_tax_category_id
),
training_fund_expenses_tax_category_id = COALESCE(
$trainingFundExpensesTaxCategoryId,
training_fund_expenses_tax_category_id
),
pension_fund_expenses_tax_category_id = COALESCE(
$pensionFundExpensesTaxCategoryId,
pension_fund_expenses_tax_category_id
),
compensation_fund_expenses_tax_category_id = COALESCE(
$compensationFundExpensesTaxCategoryId,
compensation_fund_expenses_tax_category_id
),
batched_employees_business_id = COALESCE(
$batchedEmployeesBusinessId,
batched_employees_business_id
),
batched_funds_business_id = COALESCE(
$batchedFundsBusinessId,
batched_funds_business_id
),
tax_deductions_business_id = COALESCE(
$taxDeductionsBusinessId,
tax_deductions_business_id
),
recovery_reserve_expenses_tax_category_id = COALESCE(
$recoveryReserveExpensesTaxCategoryId,
recovery_reserve_expenses_tax_category_id
),
recovery_reserve_tax_category_id = COALESCE(
$recoveryReserveTaxCategoryId,
recovery_reserve_tax_category_id
),
vacation_reserve_expenses_tax_category_id = COALESCE(
$vacationReserveExpensesTaxCategoryId,
vacation_reserve_expenses_tax_category_id
),
vacation_reserve_tax_category_id = COALESCE(
$vacationReserveTaxCategoryId,
vacation_reserve_tax_category_id
),
poalim_business_id = COALESCE(
$poalimBusinessId,
poalim_business_id
),
discount_business_id = COALESCE(
$discountBusinessId,
discount_business_id
),
otsar_hahayal_business_id = COALESCE(
$otsarHahayalBusinessId,
otsar_hahayal_business_id
),
isracard_business_id = COALESCE(
$isracardBusinessId,
isracard_business_id
),
amex_business_id = COALESCE(
$amexBusinessId,
amex_business_id
),
cal_business_id = COALESCE(
$calBusinessId,
cal_business_id
),
otsar_hahayal_credit_card_business_id = COALESCE(
$otsarHahayalCreditCardBusinessId,
otsar_hahayal_credit_card_business_id
),
etana_business_id = COALESCE(
$etanaBusinessId,
etana_business_id
),
kraken_business_id = COALESCE(
$krakenBusinessId,
kraken_business_id
),
etherscan_business_id = COALESCE(
$etherscanBusinessId,
etherscan_business_id
),
swift_business_id = COALESCE(
$swiftBusinessId,
swift_business_id
),
bank_deposit_business_id = COALESCE(
$bankDepositBusinessId,
bank_deposit_business_id
),
dividend_withholding_tax_business_id = COALESCE(
$dividendWithholdingTaxBusinessId,
dividend_withholding_tax_business_id
),
dividend_tax_category_id = COALESCE(
$dividendTaxCategoryId,
dividend_tax_category_id
),
salary_excess_expenses_tax_category_id = COALESCE(
$salaryExcessExpensesTaxCategoryId,
salary_excess_expenses_tax_category_id
),
ledger_lock = COALESCE(
$ledgerLock,
ledger_lock
),
foreign_securities_business_id = COALESCE(
$foreignSecuritiesBusinessId,
foreign_securities_business_id
),
foreign_securities_fees_category_id = COALESCE(
$foreignSecuritiesFeesCategoryId,
foreign_securities_fees_category_id
),
date_established = COALESCE(
$dateEstablished,
date_established
),
initial_accounter_year = COALESCE(
$initialAccounterYear,
initial_accounter_year
),
locality = COALESCE(
$locality,
locality
)
WHERE owner_id = $ownerId
RETURNING
user_context.*,
COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = user_context.owner_id AND r.role = 'BANK_ACCOUNT'), '{}'::uuid[]) AS bank_account_business_ids,
COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = user_context.owner_id AND r.role = 'CREDIT_CARD'), '{}'::uuid[]) AS credit_card_business_ids,
COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = user_context.owner_id AND r.role = 'CRYPTO_WALLET'), '{}'::uuid[]) AS crypto_wallet_business_ids,
COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = user_context.owner_id AND r.role = 'DIVIDEND_PAYMENT'), '{}'::uuid[]) AS dividend_payment_business_ids,
COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = user_context.owner_id AND r.role = 'VAT_EXCLUDED'), '{}'::uuid[]) AS vat_excluded_business_ids;
`;
/**
* Legacy per-institution `updateAdminContext` inputs mapped to their abstract
* {@link https://github.com/Urigo/accounter-fullstack/issues/3612 admin_business_role}.
* This lets the deprecated typed mutation inputs (poalimBusinessId, …) keep
* admin_business_roles in sync so the role-derived arrays reflect them. Phase 2
* replaces these inputs with an abstract data-source API and removes this adapter.
*/
const PROVIDER_INPUT_ROLES: ReadonlyArray<[keyof IUpdateAdminContextParams, string]> = [
['poalimBusinessId', 'BANK_ACCOUNT'],
['discountBusinessId', 'BANK_ACCOUNT'],
['otsarHahayalBusinessId', 'BANK_ACCOUNT'],
['isracardBusinessId', 'CREDIT_CARD'],
['amexBusinessId', 'CREDIT_CARD'],
['calBusinessId', 'CREDIT_CARD'],
['otsarHahayalCreditCardBusinessId', 'CREDIT_CARD'],
['etanaBusinessId', 'CRYPTO_WALLET'],
['krakenBusinessId', 'CRYPTO_WALLET'],
['etherscanBusinessId', 'CRYPTO_WALLET'],
['vatBusinessId', 'VAT_EXCLUDED'],
['taxBusinessId', 'VAT_EXCLUDED'],
['socialSecurityBusinessId', 'VAT_EXCLUDED'],
];
@Injectable({
scope: Scope.Operation,
global: true,
})
export class AdminContextProvider {
private authContext: AuthContext | null = null;
private authContextInitialized = false;
private cachedContext: AdminContext | null = null;
private cachedContextInitializing: Promise<AdminContext | null> | null = null;
constructor(
private authContextProvider: AuthContextProvider,
private db: TenantAwareDBClient,
) {}
public normalizeContext(rawContext: IGetAdminContextsResult): AdminContext {
// Data-driven data-source / classification arrays, sourced from
// admin_business_roles (see #3612) instead of hardcoded per-institution
// columns or literal UUIDs. `toIdArray` is defensive against pgTyped's array
// nullability and against callers (e.g. unit tests) that supply a row without
// the aggregated columns.
const toIdArray = (value: readonly (string | null)[] | null | undefined): string[] =>
(value ?? []).filter((id): id is string => id != null);
const bankAccountIds = toIdArray(rawContext.bank_account_business_ids);
const creditCardIds = toIdArray(rawContext.credit_card_business_ids);
const cryptoWalletIds = toIdArray(rawContext.crypto_wallet_business_ids);
const dividendPaymentBusinessIds = toIdArray(rawContext.dividend_payment_business_ids);
const vatReportExcludedBusinessNames = toIdArray(rawContext.vat_excluded_business_ids);
const salaryBatchedBusinessIds = [
rawContext.batched_employees_business_id,
rawContext.batched_funds_business_id,
].filter(Boolean) as string[];
// Internal wallets = every self-owned data source (banks + cards + crypto)
// plus the foreign-securities business, de-duplicated.
const internalWalletsIds = Array.from(
new Set(
[
...bankAccountIds,
...creditCardIds,
...cryptoWalletIds,
rawContext.foreign_securities_business_id,
].filter(Boolean) as string[],
),
);
return {
defaultLocalCurrency: formatCurrency(rawContext.default_local_currency),
defaultCryptoConversionFiatCurrency: formatCurrency(
rawContext.default_fiat_currency_for_crypto_conversions,
),
ownerId: rawContext.owner_id,
defaultTaxCategoryId: rawContext.default_tax_category_id,
locality: rawContext.locality,
ledgerLock: optionalDateToTimelessDateString(rawContext.ledger_lock) ?? undefined,
dateEstablished: optionalDateToTimelessDateString(rawContext.date_established) ?? undefined,
initialAccounterYear: rawContext.initial_accounter_year ?? undefined,
authorities: {
vatBusinessId: rawContext.vat_business_id,
inputVatTaxCategoryId: rawContext.input_vat_tax_category_id,
outputVatTaxCategoryId: rawContext.output_vat_tax_category_id,
propertyOutputVatTaxCategoryId: rawContext.property_output_vat_tax_category_id,
taxBusinessId: rawContext.tax_business_id,
taxExpensesTaxCategoryId: rawContext.tax_expenses_tax_category_id,
socialSecurityBusinessId: rawContext.social_security_business_id,
vatReportExcludedBusinessNames,
},
depreciation: {
accumulatedDepreciationTaxCategoryId: rawContext.accumulated_depreciation_tax_category_id,
rndDepreciationExpensesTaxCategoryId: rawContext.rnd_depreciation_expenses_tax_category_id,
gnmDepreciationExpensesTaxCategoryId: rawContext.gnm_depreciation_expenses_tax_category_id,
marketingDepreciationExpensesTaxCategoryId:
rawContext.marketing_depreciation_expenses_tax_category_id,
},
bankDeposits: {
bankDepositBusinessId: rawContext.bank_deposit_business_id,
bankDepositInterestIncomeTaxCategoryId:
rawContext.bank_deposit_interest_income_tax_category_id,
},
foreignSecurities: {
foreignSecuritiesBusinessId: rawContext.foreign_securities_business_id,
foreignSecuritiesFeesCategoryId: rawContext.foreign_securities_fees_category_id,
},
dividends: {
dividendWithholdingTaxBusinessId: rawContext.dividend_withholding_tax_business_id,
dividendTaxCategoryId: rawContext.dividend_tax_category_id,
dividendPaymentBusinessIds,
dividendBusinessIds: [
...(rawContext.dividend_withholding_tax_business_id
? [rawContext.dividend_withholding_tax_business_id]
: []),
...dividendPaymentBusinessIds,
],
},
businessTrips: {
businessTripTaxCategoryId: rawContext.business_trip_tax_category_id,
businessTripTagId: rawContext.business_trip_tag_id,
},
financialAccounts: {
poalimBusinessId: rawContext.poalim_business_id,
discountBusinessId: rawContext.discount_business_id,
otsarHahayalBusinessId: rawContext.otsar_hahayal_business_id,
swiftBusinessId: rawContext.swift_business_id,
isracardBusinessId: rawContext.isracard_business_id,
amexBusinessId: rawContext.amex_business_id,
calBusinessId: rawContext.cal_business_id,
otsarHahayalCreditCardBusinessId: rawContext.otsar_hahayal_credit_card_business_id,
etanaBusinessId: rawContext.etana_business_id,
krakenBusinessId: rawContext.kraken_business_id,
etherScanBusinessId: rawContext.etherscan_business_id,
foreignSecuritiesBusinessId: rawContext.foreign_securities_business_id,
bankAccountIds,
creditCardIds,
internalWalletsIds,
},
salaries: {
zkufotExpensesTaxCategoryId: rawContext.zkufot_expenses_tax_category_id,
zkufotIncomeTaxCategoryId: rawContext.zkufot_income_tax_category_id,
socialSecurityExpensesTaxCategoryId: rawContext.social_security_expenses_tax_category_id,
salaryExpensesTaxCategoryId: rawContext.salary_expenses_tax_category_id,
trainingFundExpensesTaxCategoryId: rawContext.training_fund_expenses_tax_category_id,
compensationFundExpensesTaxCategoryId:
rawContext.compensation_fund_expenses_tax_category_id,
pensionExpensesTaxCategoryId: rawContext.pension_fund_expenses_tax_category_id,
batchedEmployeesBusinessId: rawContext.batched_employees_business_id,
batchedFundsBusinessId: rawContext.batched_funds_business_id,
salaryBatchedBusinessIds,
taxDeductionsBusinessId: rawContext.tax_deductions_business_id,
recoveryReserveExpensesTaxCategoryId: rawContext.recovery_reserve_expenses_tax_category_id,
recoveryReserveTaxCategoryId: rawContext.recovery_reserve_tax_category_id,
vacationReserveExpensesTaxCategoryId: rawContext.vacation_reserve_expenses_tax_category_id,
vacationReserveTaxCategoryId: rawContext.vacation_reserve_tax_category_id,
},
crossYear: {
expensesToPayTaxCategoryId: rawContext.expenses_to_pay_tax_category_id,
expensesInAdvanceTaxCategoryId: rawContext.expenses_in_advance_tax_category_id,
incomeToCollectTaxCategoryId: rawContext.income_to_collect_tax_category_id,
incomeInAdvanceTaxCategoryId:
rawContext.income_in_advance_tax_category_id ?? rawContext.default_tax_category_id,
},
general: {
taxCategories: {
exchangeRateTaxCategoryId: rawContext.exchange_rate_tax_category_id,
incomeExchangeRateTaxCategoryId: rawContext.income_exchange_rate_tax_category_id,
exchangeRevaluationTaxCategoryId: rawContext.exchange_rate_revaluation_tax_category_id,
feeTaxCategoryId: rawContext.fee_tax_category_id,
generalFeeTaxCategoryId: rawContext.general_fee_tax_category_id,
fineTaxCategoryId: rawContext.fine_tax_category_id,
untaxableGiftsTaxCategoryId: rawContext.untaxable_gifts_tax_category_id,
balanceCancellationTaxCategoryId: rawContext.balance_cancellation_tax_category_id,
developmentForeignTaxCategoryId: rawContext.development_foreign_tax_category_id,
developmentLocalTaxCategoryId: rawContext.development_local_tax_category_id,
salaryExcessExpensesTaxCategoryId: rawContext.salary_excess_expenses_tax_category_id,
},
},
};
}
public async getAdminContext(): Promise<AdminContext | null> {
if (this.cachedContext) {
return this.cachedContext;
}
const ownerId = await this.resolveOwnerIdForRequest();
const contexts = await getAdminContexts.run({ ownerIds: [ownerId] }, this.db);
const context = contexts[0] ? this.normalizeContext(contexts[0]) : null;
this.cachedContext = context;
return context;
}
public async getVerifiedAdminContext() {
if (this.cachedContext) {
return this.cachedContext;
}
this.cachedContextInitializing ??= this.getAdminContext();
const context = await this.cachedContextInitializing;
if (!context) {
throw new GraphQLError('Admin context not found for the authenticated user.', {
extensions: { code: 'FORBIDDEN' },
});
}
return context;
}
public async updateAdminContext(params: IUpdateAdminContextParams): Promise<AdminContext | null> {
const ownerId = await this.resolveOwnerIdForRequest();
this.cachedContext = null;
// Persist role rows for any legacy per-institution inputs BEFORE the update,
// so the RETURNING aggregates below already include the change.
await this.syncProviderBusinessRoles(ownerId, params);
const updatedContexts = await updateAdminContext.run({ ...params, ownerId }, this.db);
if (updatedContexts.length >= 1) {
const normalizedContext = this.normalizeContext(updatedContexts[0]);
this.cachedContext = normalizedContext;
return normalizedContext;
}
return null;
}
/**
* Write-through adapter for the legacy typed provider/authority business inputs:
* upsert the matching admin_business_roles rows so the role-derived arrays stay
* consistent when those inputs are used. Additive only (ON CONFLICT DO NOTHING) —
* removals are owned by the Phase 2 data-source API. No-op (and no query) when
* the update carries none of these inputs.
*/
private async syncProviderBusinessRoles(
ownerId: string,
params: IUpdateAdminContextParams,
): Promise<void> {
const values: unknown[] = [ownerId];
const rows: string[] = [];
for (const [key, role] of PROVIDER_INPUT_ROLES) {
const businessId = params[key];
if (typeof businessId === 'string' && businessId.length > 0) {
rows.push(
`($1, $${values.length + 1}, $${values.length + 2}::accounter_schema.admin_business_role)`,
);
values.push(businessId, role);
}
}
if (rows.length === 0) {
return;
}
await this.db.query(
`INSERT INTO accounter_schema.admin_business_roles (owner_id, business_id, role)
VALUES ${rows.join(', ')}
ON CONFLICT DO NOTHING`,
values,
);
}
private async batchAdminContextsByOwnerIds(ownerIds: readonly string[]) {
const uniqueOwnerIds = Array.from(new Set(ownerIds));
const contexts = await getAdminContexts.run({ ownerIds: uniqueOwnerIds }, this.db);
const contextsByOwnerId = new Map(contexts.map(c => [c.owner_id, c]));
return ownerIds.map(id => {
const context = contextsByOwnerId.get(id);
return context ? this.normalizeContext(context) : null;
});
}
public adminContextByOwnerIdLoader = new DataLoader((ownerIds: readonly string[]) =>
this.batchAdminContextsByOwnerIds(ownerIds),
);
public clearCache() {
this.cachedContext = null;
}
/**
* Resolve the owner business for this request.
* - Single-business read scope selects that business.
* - Multi-business scope prefers the primary tenant business only when it is
* part of the scope; otherwise the first scoped business is used.
* - No scope falls back to the primary tenant business.
*/
private async resolveOwnerIdForRequest(): Promise<string> {
await this.ensureAuthContext();
if (!this.authContext) {
throw new GraphQLError(
'Auth context not available. AdminContextProvider requires active authentication.',
{ extensions: { code: 'UNAUTHENTICATED' } },
);
}
const ownerId = resolveWriteTargetBusinessId(
this.authContext.tenant.businessId,
this.authContext.activeReadScope,
);
if (!ownerId) {
throw new Error('AdminContextProvider: ownerId not found in context (currentUser)');
}
return ownerId;
}
/**
* Lazy initialization of auth context on first use.
* This ensures the async provider is called only when needed.
*/
private async ensureAuthContext(): Promise<void> {
if (this.authContextInitialized) {
return;
}
this.authContext = await this.authContextProvider.getAuthContext();
this.authContextInitialized = true;
}
}