per-user trade-direction access scope

This commit is contained in:
Marshal
2026-08-02 22:29:58 +00:00
parent c055abe8c1
commit f4fd469643
47 changed files with 1451 additions and 107 deletions

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayUnique, IsIn } from 'class-validator';
import { Freight } from '@edr/types';
export class UpsertUserTradeAccessDto {
@ApiProperty({
description:
'Trade directions the user may see. All three (or no config row) = unrestricted; empty array = sees nothing.',
isArray: true,
enum: ['IMPORT', 'EXPORT', 'DOMESTIC'],
example: ['IMPORT', 'DOMESTIC'],
})
@ArrayUnique()
@IsIn(['IMPORT', 'EXPORT', 'DOMESTIC'], { each: true })
directions!: Freight.ScheduleTradeDirection[];
}

View File

@@ -0,0 +1,27 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity, Index } from 'typeorm';
/**
* Which trade directions (IMPORT / EXPORT / DOMESTIC=Intercity) a backoffice
* user may see. No row, or all three directions, means unrestricted.
*/
@Entity({ schema: 'freight', name: 'user_trade_access' })
export class UserTradeAccess extends BaseEntity {
/** IAM user id (iam.users) — no FK, iam schema is externally owned. */
@Index()
@Column({ name: 'user_id', type: 'uuid', unique: true })
userId!: string;
@Column({ name: 'directions', type: 'text', default: '' })
directionsRaw!: string;
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
updatedById!: string | null;
get directions(): Freight.ScheduleTradeDirection[] {
return this.directionsRaw
? (this.directionsRaw.split(',') as Freight.ScheduleTradeDirection[])
: [];
}
}

View File

@@ -0,0 +1,100 @@
import { Freight } from '@edr/types';
import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm';
/**
* Resolve the effective direction list for a query.
*
* @param allowed the user's scope — null = unrestricted
* @param requested an explicit ?tradeDirection=… filter, if any
* @returns directions to filter by, `null` = no filter, `[]` = show nothing
*/
export function scopedDirections(
allowed: Freight.ScheduleTradeDirection[] | null,
requested?: string | null,
): string[] | null {
if (!allowed) return requested ? [requested] : null;
if (!requested) return [...allowed];
return allowed.includes(requested as Freight.ScheduleTradeDirection)
? [requested]
: [];
}
/**
* Apply a direction scope to a query builder column.
* `dirs = null` → untouched; `dirs = []` → matches nothing.
*/
export function applyDirectionScope<T extends WhereExpressionBuilder>(
qb: T,
column: string,
dirs: string[] | null,
): T {
if (dirs === null) return qb;
if (dirs.length === 0) {
qb.andWhere('1 = 0');
return qb;
}
// Unique param name so multiple scopes can coexist on one query.
const param = `scopeDirs_${column.replace(/\W/g, '_')}`;
qb.andWhere(`${column} IN (:...${param})`, { [param]: dirs });
return qb;
}
/**
* SQL-fragment form of {@link applyDirectionScope} for fluent query chains:
* `.andWhere(f.sql, f.params)`. `dirs = null/undefined` → TRUE (no-op).
*/
export function directionScopeSql(
column: string,
dirs: string[] | null | undefined,
): { sql: string; params: Record<string, unknown> } {
if (!dirs) return { sql: 'TRUE', params: {} };
if (dirs.length === 0) return { sql: 'FALSE', params: {} };
const param = `scopeDirs_${column.replace(/\W/g, '_')}`;
return { sql: `${column} IN (:...${param})`, params: { [param]: dirs } };
}
/**
* SQL-fragment form of {@link applyBookingRefDirectionScope}: hides rows whose
* varchar ref column points at a booking outside the scope; rows that do not
* point at a booking stay visible (they carry no direction to scope by).
*/
export function bookingRefScopeSql(
refColumn: string,
dirs: string[] | null | undefined,
): { sql: string; params: Record<string, unknown> } {
if (!dirs) return { sql: 'TRUE', params: {} };
const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`;
const disallowed = dirs.length
? `b.trade_direction NOT IN (:...${param})`
: 'TRUE';
return {
sql: `NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`,
params: dirs.length ? { [param]: dirs } : {},
};
}
/**
* Scope rows whose direction lives on a related booking referenced by a
* varchar id column (invoices.source_id, payments.ref_id). Rows that do not
* point at a booking stay visible — they carry no direction to scope by.
*/
export function applyBookingRefDirectionScope<T>(
qb: SelectQueryBuilder<T & object>,
refColumn: string,
dirs: string[] | null,
): SelectQueryBuilder<T & object> {
if (dirs === null) return qb;
const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`;
const disallowed = dirs.length
? `b.trade_direction NOT IN (:...${param})`
: 'TRUE';
qb.andWhere(
new Brackets((w) => {
w.where(
`NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`,
);
}),
);
if (dirs.length) qb.setParameter(param, dirs);
return qb;
}

View File

@@ -0,0 +1,67 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Param,
ParseUUIDPipe,
Put,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { StaffReference } from '../../common/booking-guards';
import { isFreightApprovalAdmin } from '../../common/freight-permission.util';
import { UpsertUserTradeAccessDto } from './dto/upsert-user-trade-access.dto';
import { UserTradeAccessService } from './user-trade-access.service';
@ApiTags('user-trade-access')
@Controller('user-trade-access')
@StaffReference()
@ApiBearerAuth()
export class UserTradeAccessController {
constructor(private readonly service: UserTradeAccessService) {}
@Get()
@ApiOperation({ summary: 'List every configured user trade-direction scope' })
list(@CurrentUser() user: TCurrentUser) {
this.assertAdmin(user);
return this.service.listConfigs();
}
@Get('me')
@ApiOperation({ summary: "Current user's effective trade-direction scope" })
async me(@CurrentUser() user: TCurrentUser) {
const allowed = await this.service.resolveAllowedDirections(user);
return {
restricted: allowed !== null,
directions: allowed ?? ['IMPORT', 'EXPORT', 'DOMESTIC'],
};
}
@Put(':userId')
@ApiOperation({
summary: 'Set the trade directions a backoffice user may see',
})
upsert(
@Param('userId', ParseUUIDPipe) userId: string,
@Body() dto: UpsertUserTradeAccessDto,
@CurrentUser() user: TCurrentUser,
) {
this.assertAdmin(user);
return this.service.upsert(
userId,
dto.directions,
(user as { id?: string } | null)?.id ?? null,
);
}
private assertAdmin(user: TCurrentUser) {
if (!isFreightApprovalAdmin(user)) {
throw new ForbiddenException(
'Only super or organization admins can manage trade-direction access',
);
}
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserTradeAccess } from './entities/user-trade-access.entity';
import { UserTradeAccessController } from './user-trade-access.controller';
import { UserTradeAccessRepository } from './user-trade-access.repository';
import { UserTradeAccessService } from './user-trade-access.service';
@Module({
imports: [TypeOrmModule.forFeature([UserTradeAccess])],
controllers: [UserTradeAccessController],
providers: [UserTradeAccessService, UserTradeAccessRepository],
exports: [UserTradeAccessService],
})
export class UserTradeAccessModule {}

View File

@@ -0,0 +1,23 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserTradeAccess } from './entities/user-trade-access.entity';
@Injectable()
export class UserTradeAccessRepository extends BaseRepository<UserTradeAccess> {
constructor(
@InjectRepository(UserTradeAccess) repository: Repository<UserTradeAccess>,
) {
super(repository);
}
findByUserId(userId: string): Promise<UserTradeAccess | null> {
return this.repository.findOne({ where: { userId } });
}
findAllConfigs(): Promise<UserTradeAccess[]> {
return this.repository.find({ order: { updatedAt: 'DESC' } });
}
}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { Freight } from '@edr/types';
import { isFreightApprovalAdmin } from '../../common/freight-permission.util';
import { UserTradeAccess } from './entities/user-trade-access.entity';
import { UserTradeAccessRepository } from './user-trade-access.repository';
const ALL: Freight.ScheduleTradeDirection[] = ['IMPORT', 'EXPORT', 'DOMESTIC'];
/** Loose current-user shape: JWT payloads and TCurrentUser both fit. */
export type ScopeUser =
| ({ id?: string; sub?: string; roles?: { key?: string }[] } & object)
| null
| undefined;
export type UserTradeAccessView = {
userId: string;
directions: Freight.ScheduleTradeDirection[];
updatedAt: Date;
};
@Injectable()
export class UserTradeAccessService {
constructor(private readonly repository: UserTradeAccessRepository) {}
async listConfigs(): Promise<UserTradeAccessView[]> {
const rows = await this.repository.findAllConfigs();
return rows.map((r) => this.toView(r));
}
async upsert(
userId: string,
directions: Freight.ScheduleTradeDirection[],
actorId?: string | null,
): Promise<UserTradeAccessView> {
// Normalize to canonical order so "all three" compares reliably.
const normalized = ALL.filter((d) => directions.includes(d));
const existing = await this.repository.findByUserId(userId);
const saved = existing
? await this.repository.update(existing.id, {
directionsRaw: normalized.join(','),
updatedById: actorId ?? null,
})
: await this.repository.create({
userId,
directionsRaw: normalized.join(','),
updatedById: actorId ?? null,
});
return this.toView(saved as UserTradeAccess);
}
/**
* Effective scope for the current user.
* `null` = unrestricted (no config, all three directions, admin, or no user
* on the request — routes without auth cannot be scoped).
*/
async resolveAllowedDirections(
user: ScopeUser,
): Promise<Freight.ScheduleTradeDirection[] | null> {
const userId = user?.id ?? user?.sub;
if (!userId) return null;
if (isFreightApprovalAdmin(user)) return null;
const row = await this.repository.findByUserId(userId);
if (!row) return null;
const dirs = row.directions;
if (dirs.length >= ALL.length) return null;
return dirs;
}
private toView(row: UserTradeAccess): UserTradeAccessView {
return {
userId: row.userId,
directions: row.directions,
updatedAt: row.updatedAt,
};
}
}