fix(backoffice): stop sharing the position cookie with Smart Office

Both apps wrote a cookie named `current-position-id` but stored
different ids in it — freight the `employeePositionId`, Smart Office the
`position.id`. On a shared domain each login overwrote the other's desk
selection, and the loser silently fell back to the first position.

Freight now uses `freight-current-position-id` through a small helper
that reads the old name once, so a session live across the deploy keeps
its desk, and clears it on every write.

Also mounts the position switcher in the freight dashboard header. It
only existed under /performance-management, so on every other page a
two-desk user had no way to switch and was stuck on whatever
`useAuthUser` defaulted to. It now hides below two positions rather than
showing a one-option dropdown to the single-desk majority.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathnael
2026-09-01 07:04:10 +00:00
parent db9d6e49c7
commit 7fd5616188
9 changed files with 92 additions and 42 deletions

View File

@@ -4,24 +4,17 @@ import {
HttpException,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import type { Request, Response } from 'express';
} from "@nestjs/common";
import { Observable, tap } from "rxjs";
import type { Request, Response } from "express";
import { AuditService } from './audit.service';
import {
auditEndpointMatcher,
type MatchedAuditEndpoint,
} from './audit-endpoint-matcher';
import {
isAuditableActor,
resolveAuditActor,
type AuditActorSource,
} from './audit-actor';
import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer';
import { AuditService } from "./audit.service";
import { auditEndpointMatcher, type MatchedAuditEndpoint } from "./audit-endpoint-matcher";
import { isAuditableActor, resolveAuditActor, type AuditActorSource } from "./audit-actor";
import { redactUrlQuery, sanitizeRequestPayload } from "./audit.sanitizer";
/** Methods that can change state. Everything else is never audited. */
const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const AUDITED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/** `error_message` ceiling — stack traces do not belong in this column. */
const MAX_ERROR_LENGTH = 2_000;
@@ -52,7 +45,7 @@ export class AuditInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
// Non-HTTP contexts (the RabbitMQ microservice transport) have no request.
if (context.getType() !== 'http') return next.handle();
if (context.getType() !== "http") return next.handle();
const httpContext = context.switchToHttp();
const request = httpContext.getRequest<RequestWithUser>();
@@ -72,10 +65,7 @@ export class AuditInterceptor implements NestInterceptor {
const startedAt = Date.now();
// The body is captured up front: handlers are free to mutate the DTO they
// are given, so reading it after the fact can record post-mutation values.
const requestPayload = sanitizeRequestPayload(
request.body,
request.files ?? request.file,
);
const requestPayload = sanitizeRequestPayload(request.body, request.files ?? request.file);
return next.handle().pipe(
tap({
@@ -137,7 +127,6 @@ export class AuditInterceptor implements NestInterceptor {
resourceId: matched.resourceId,
request: requestPayload,
ipAddress: resolveIp(request),
userAgent: request.headers['user-agent'] ?? null,
requestId: resolveRequestId(request),
durationMs: Date.now() - startedAt,
});
@@ -154,10 +143,10 @@ function resolveErrorMessage(error: unknown): string | null {
if (error instanceof HttpException) {
const response = error.getResponse();
const message =
typeof response === 'string'
typeof response === "string"
? response
: ((response as { message?: unknown })?.message ?? error.message);
const text = Array.isArray(message) ? message.join('; ') : String(message);
const text = Array.isArray(message) ? message.join("; ") : String(message);
return text.slice(0, MAX_ERROR_LENGTH);
}
@@ -171,19 +160,19 @@ function resolveErrorMessage(error: unknown): string | null {
* entry (the original client) taken.
*/
function resolveIp(request: Request): string | null {
const forwarded = request.headers['x-forwarded-for'];
const forwarded = request.headers["x-forwarded-for"];
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const candidate = raw?.split(',')[0]?.trim() || request.ip;
const candidate = raw?.split(",")[0]?.trim() || request.ip;
if (!candidate) return null;
// Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column
// accepts but which reads badly and breaks grouping by address.
return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate;
return candidate.startsWith("::ffff:") ? candidate.slice(7) : candidate;
}
/** Correlation id from the proxy/tracing layer, when present. */
function resolveRequestId(request: RequestWithUser): string | null {
const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id'];
const header = request.headers["x-request-id"] ?? request.headers["x-correlation-id"];
const value = Array.isArray(header) ? header[0] : header;
return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null;
}