{"record":{"id":"7a528f59ab10cc80","repo":"fullstackhero/dotnet-starter-kit","slug":"tenant-context-is-required-getinvoicesqueryhandler","errorCode":null,"errorMessage":"Tenant context is required.","messagePattern":"Tenant context is required\\.","errorType":"exception","errorClass":"UnauthorizedException","httpStatus":401,"severity":"error","filePath":"src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs","lineNumber":27,"sourceCode":"using Microsoft.EntityFrameworkCore;\n\nnamespace FSH.Modules.Billing.Features.v1.Invoices.GetInvoices;\n\npublic sealed class GetInvoicesQueryHandler(\n    BillingDbContext dbContext,\n    IMultiTenantContextAccessor<AppTenantInfo> tenantAccessor)\n    : IQueryHandler<GetInvoicesQuery, PagedResponse<InvoiceDto>>\n{\n    public async ValueTask<PagedResponse<InvoiceDto>> Handle(GetInvoicesQuery query, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(query);\n\n        // BillingDbContext is not tenant-filtered: only root gets the cross-tenant view (optionally\n        // narrowed via query.TenantId); every other caller is forced to its own tenant.\n        var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id\n            ?? throw new UnauthorizedException(\"Tenant context is required.\");\n        var isRoot = callerTenantId == MultitenancyConstants.Root.Id;\n        var tenantFilter = isRoot ? query.TenantId : callerTenantId;\n\n        var q = dbContext.Invoices.AsNoTracking().Include(i => i.LineItems).AsQueryable();\n        if (!string.IsNullOrWhiteSpace(tenantFilter))\n        {\n            q = q.Where(i => i.TenantId == tenantFilter);\n        }\n        if (query.Status is not null)\n        {\n            q = q.Where(i => i.Status == query.Status);\n        }\n        if (query.PeriodYear is not null)\n        {\n            q = q.Where(i => i.PeriodYear == query.PeriodYear);\n        }\n        if (query.PeriodMonth is not null)\n        {\n            q = q.Where(i => i.PeriodMonth == query.PeriodMonth);\n        }","sourceCodeStart":9,"sourceCodeEnd":45,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs#L9-L45","documentation":"GetInvoicesQueryHandler lists invoices from the non-tenant-filtered BillingDbContext, so it must scope results itself: root may view across tenants (optionally narrowed by query.TenantId) while every other caller is forced to its own tenant id. If TenantInfo is null (no tenant context), it throws UnauthorizedException(\"Tenant context is required.\") rather than returning an unscoped page.","triggerScenarios":"GET /invoices (v1 paginated list) without a resolvable tenant: no __tenant__ token/header, host not mapped to a tenant, request outside multitenant middleware, or invocation from a background worker without a tenant scope.","commonSituations":"Calling the list endpoint from a Hangfire job or scheduled report generator; Postman collection copied from a non-tenant admin route; tenant host strategy broken after a reverse-proxy rewrite strips the original host header.","solutions":["Attach a tenant identifier to the request (X-Tenant-Id or __tenant__) so Finbuckle resolves TenantInfo.","Check proxy/load-balancer config preserves the Host/X-Forwarded-Host header the tenant strategy matches on.","For non-HTTP callers, wrap execution in a tenant scope or query via a root-authorized path.","Confirm the tenant exists in the tenant store; unknown identifiers resolve to null TenantInfo."],"exampleFix":"// before: header stripped by proxy\napiFetch('/invoices');\n\n// after: set tenant explicitly\napiFetch('/invoices', { headers: { 'X-Tenant-Id': tenantId } });","handlingStrategy":"validation","validationCode":"if (!tenantId) throw new Error('Tenant context required for invoice listing; set X-Tenant-Id or __tenant__');\n// root callers may add ?tenantId=<target> to narrow the list","typeGuard":"function hasTenant(t) { return typeof t === 'string' && t.length > 0; }","tryCatchPattern":"try { return await apiFetch('/invoices?pageNumber=1&pageSize=20'); }\ncatch (e) { if (isUnauthorized(e)) { redirectToTenantSelection(); } throw e; }","preventionTips":["Always route invoice list calls through apiFetch so the tenant header is injected.","For scheduled exports, run the job inside a tenant scope instead of calling the HTTP endpoint unscoped.","Verify proxies don't strip Host/X-Forwarded-Host needed by host-based tenant resolution."],"tags":["multitenancy","authorization","billing","pagination"],"backgroundTag":"authentication-required","analyzedSha":"3f2959e683e9f83f13e55e1678c9119f63c7e8e5","analyzedAt":"2026-09-15T22:20:53.684Z","contentChangedAt":"2026-09-15T22:20:53.684Z","schemaVersion":2},"datasetVersion":"2026-09-16T04:17:20.429Z"}