feat(F-143): completed feature
This commit is contained in:
139
project/src/modules/reporting/api/reporting.routes.test.ts
Normal file
139
project/src/modules/reporting/api/reporting.routes.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { AppError, errorEnvelope } from '../../../shared/errors.js';
|
||||
import { registerReportingRoutes } from './reporting.routes.js';
|
||||
import type { ReportingRoutesDeps } from '../index.js';
|
||||
|
||||
const created: FastifyInstance[] = [];
|
||||
|
||||
const ADMIN = { id: 'a1', email: 'ana@example.com', role: 'admin' };
|
||||
const CUSTOMER = { id: 'c1', email: 'c1@example.com', role: 'customer' };
|
||||
|
||||
/**
|
||||
* Minimal app: reporting routes + mocked authenticator (no DB).
|
||||
* Mirrors build-app.ts error mapping + serializer so AppError(400/403) are
|
||||
* surfaced with their real status codes (routes throw AppError; build-app maps it).
|
||||
*/
|
||||
async function buildApp(authenticatedUser: unknown) {
|
||||
const app = Fastify();
|
||||
created.push(app);
|
||||
app.setSerializerCompiler(() => (payload: unknown) => JSON.stringify(payload));
|
||||
app.setErrorHandler(async (err, _request, reply) => {
|
||||
if (err instanceof AppError) {
|
||||
return reply
|
||||
.code(err.statusCode)
|
||||
.send(
|
||||
errorEnvelope(err.statusCode, err.code, err.message, 'test-request-id', err.details),
|
||||
);
|
||||
}
|
||||
return reply
|
||||
.code(500)
|
||||
.send(errorEnvelope(500, 'INTERNAL_ERROR', 'Internal Server Error', 'test-request-id'));
|
||||
});
|
||||
const deps = {
|
||||
authenticate: vi.fn().mockResolvedValue(authenticatedUser),
|
||||
} as unknown as ReportingRoutesDeps;
|
||||
await registerReportingRoutes(app, deps);
|
||||
await app.ready();
|
||||
return { app };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const app of created) {
|
||||
try {
|
||||
await app.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
created.length = 0;
|
||||
});
|
||||
|
||||
describe('GET /reporting/filters/schema (REPORTING_VIEW)', () => {
|
||||
it('admin receives the schema contract + own grants (AC2)', async () => {
|
||||
const { app } = await buildApp(ADMIN);
|
||||
const res = await app.inject({ method: 'GET', url: '/reporting/filters/schema' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body ?? '') as {
|
||||
filterSchema: { comparison: { modes: string[] }; dataAvailability: Record<string, string> };
|
||||
permissions: { role: string; grants: string[] };
|
||||
};
|
||||
expect(body.filterSchema.comparison.modes).toEqual([
|
||||
'none',
|
||||
'previous_equal',
|
||||
'previous_calendar',
|
||||
]);
|
||||
expect(body.filterSchema.dataAvailability.grossSales).toBe('available');
|
||||
expect(body.filterSchema.dataAvailability.netSales).toBe('unavailable');
|
||||
expect(body.permissions.role).toBe('admin');
|
||||
expect(body.permissions.grants).toContain('REPORTING_VIEW');
|
||||
expect(body.permissions.grants).toContain('REPORTING_SALES');
|
||||
});
|
||||
|
||||
it('customer is forbidden (403) — reporting is backoffice-only', async () => {
|
||||
const { app } = await buildApp(CUSTOMER);
|
||||
const res = await app.inject({ method: 'GET', url: '/reporting/filters/schema' });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /reporting/filters/validate (REPORTING_SALES)', () => {
|
||||
const BASE =
|
||||
'/reporting/filters/validate?from=2026-08-01T00:00:00Z&to=2026-08-31T23:59:59Z';
|
||||
|
||||
it('validates a real query and computes the comparison range (AC3)', async () => {
|
||||
const { app } = await buildApp(ADMIN);
|
||||
const res = await app.inject({ method: 'GET', url: `${BASE}&compare=previous_equal` });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body ?? '') as {
|
||||
filters: { range: { from: string; to: string }; compare: string };
|
||||
comparison: { range: { from: string; to: string } | null };
|
||||
};
|
||||
const start = new Date(body.filters.range.from).getTime();
|
||||
const end = new Date(body.filters.range.to).getTime();
|
||||
expect(body.filters.compare).toBe('previous_equal');
|
||||
expect(body.comparison.range).not.toBeNull();
|
||||
// previous_equal: prior window ends exactly at this range's start (same duration).
|
||||
expect(new Date(body.comparison.range!.to).getTime()).toBe(start);
|
||||
expect(new Date(body.comparison.range!.from).getTime()).toBeLessThan(start);
|
||||
expect(end - start).toBe(
|
||||
new Date(body.comparison.range!.to).getTime() - new Date(body.comparison.range!.from).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to compare=none and a null range when omitted (AC1/AC3)', async () => {
|
||||
const { app } = await buildApp(ADMIN);
|
||||
const res = await app.inject({ method: 'GET', url: BASE });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body ?? '') as {
|
||||
filters: { compare: string };
|
||||
comparison: { range: unknown };
|
||||
};
|
||||
expect(body.filters.compare).toBe('none');
|
||||
expect(body.comparison.range).toBeNull();
|
||||
});
|
||||
|
||||
it('returns 400 on inverted range (AC1)', async () => {
|
||||
const { app } = await buildApp(ADMIN);
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/reporting/filters/validate?from=2026-08-31T00:00:00Z&to=2026-08-01T00:00:00Z',
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 on missing required from/to (AC1)', async () => {
|
||||
const { app } = await buildApp(ADMIN);
|
||||
const res = await app.inject({ method: 'GET', url: '/reporting/filters/validate' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('customer is forbidden even with a valid query (RBAC enforced via HTTP, AC5)', async () => {
|
||||
const { app } = await buildApp(CUSTOMER);
|
||||
const res = await app.inject({ method: 'GET', url: BASE });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
62
project/src/modules/reporting/api/reporting.routes.ts
Normal file
62
project/src/modules/reporting/api/reporting.routes.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { FastifyInstance, FastifySchema } from 'fastify';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { REPORTING_FILTER_META, comparisonRange, reportingFiltersSchema } from '../application/filters.js';
|
||||
import { requireReportingPermission, userReportingPermissions } from '../domain/permissions.js';
|
||||
|
||||
export interface ReportingRoutesDeps {
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reporting routes (F-143).
|
||||
*
|
||||
* Backend-only contracts + RBAC foundation. NO report data is read here
|
||||
* (F-144+ owns report data). `ReportingFilterMeta.dataAvailability` is metadata
|
||||
* only — it never converts an unavailable metric into a value.
|
||||
*/
|
||||
export async function registerReportingRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: ReportingRoutesDeps,
|
||||
): Promise<void> {
|
||||
const filtersSchemaRoute: FastifySchema = {
|
||||
tags: ['Reporting'],
|
||||
summary: 'Reporting filter schema + RBAC contract',
|
||||
description:
|
||||
"Returns the shared reporting filter schema, comparison contract, data-availability metadata and the caller's reporting permissions. Requires REPORTING_VIEW.",
|
||||
querystring: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
// No 200 response schema: the DTO is dynamic metadata; let it pass through
|
||||
// unmodified (matches security.routes.ts pattern, avoids field stripping).
|
||||
app.get('/reporting/filters/schema', { schema: filtersSchemaRoute }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireReportingPermission(user, 'REPORTING_VIEW');
|
||||
return reply.send({
|
||||
filterSchema: REPORTING_FILTER_META,
|
||||
comparison: REPORTING_FILTER_META.comparison,
|
||||
dataAvailability: REPORTING_FILTER_META.dataAvailability,
|
||||
permissions: {
|
||||
role: user.role,
|
||||
grants: userReportingPermissions(user),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const validateSchemaRoute: FastifySchema = {
|
||||
tags: ['Reporting'],
|
||||
summary: 'Validate reporting filters',
|
||||
description:
|
||||
'Parses, validates and normalizes a reporting filter query and computes the comparison range. Requires REPORTING_SALES.',
|
||||
querystring: { type: 'object' },
|
||||
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/reporting/filters/validate', { schema: validateSchemaRoute }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireReportingPermission(user, 'REPORTING_SALES');
|
||||
const filters = parseJson(reportingFiltersSchema, request.query ?? {});
|
||||
const comparison = comparisonRange(filters.range, filters.compare);
|
||||
return reply.send({ ok: true, filters, comparison: { range: comparison } });
|
||||
});
|
||||
}
|
||||
289
project/src/modules/reporting/application/filters.ts
Normal file
289
project/src/modules/reporting/application/filters.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { z } from 'zod';
|
||||
import type {
|
||||
ComparisonMode,
|
||||
ComparisonRange,
|
||||
DateRange,
|
||||
ReportingFilterMeta,
|
||||
ReportingFilters,
|
||||
} from '../domain/filters.js';
|
||||
import { REPORTING_CHANNELS, REPORTING_COMPARISON, REPORTING_GROUP_BY } from '../domain/filters.js';
|
||||
|
||||
/** Server-side safety cap for page size (F-142 §7/§11). */
|
||||
export const REPORTING_PAGE_SIZE_MAX = 200;
|
||||
/** Default page size when omitted. */
|
||||
export const REPORTING_PAGE_SIZE_DEFAULT = 50;
|
||||
|
||||
/**
|
||||
* Accepts a single ISO datetime string or an array (Fastify querystring yields
|
||||
* string vs array); missing -> undefined. Used for repeatable UUID params.
|
||||
*/
|
||||
function repeatableUuid() {
|
||||
return z.preprocess(
|
||||
(val: unknown) => (val === undefined ? undefined : Array.isArray(val) ? val : [val]),
|
||||
z.array(z.uuid()).optional(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Repeatable free-text enum (e.g. order `state`). */
|
||||
function repeatableString() {
|
||||
return z.preprocess(
|
||||
(val: unknown) => (val === undefined ? undefined : Array.isArray(val) ? val : [val]),
|
||||
z.array(z.string().min(1).max(40)).optional(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared reporting filter schema (F-142 §6). Validates + normalizes raw query
|
||||
* params into a `ReportingFilters` value. `from`/`to` use `[from,to)` semantics
|
||||
* (inclusive start, exclusive end); `from<to` is enforced.
|
||||
*/
|
||||
export const reportingFiltersSchema = z
|
||||
.object({
|
||||
from: z
|
||||
.string()
|
||||
.refine((s) => !Number.isNaN(Date.parse(s)), { message: 'Invalid ISO 8601 datetime' }),
|
||||
to: z
|
||||
.string()
|
||||
.refine((s) => !Number.isNaN(Date.parse(s)), { message: 'Invalid ISO 8601 datetime' }),
|
||||
compare: z.enum(REPORTING_COMPARISON).default('none'),
|
||||
channel: z.enum(REPORTING_CHANNELS).default('all'),
|
||||
storeId: repeatableUuid(),
|
||||
terminalId: repeatableUuid(),
|
||||
cashierId: repeatableUuid(),
|
||||
paymentMethodId: repeatableUuid(),
|
||||
productId: repeatableUuid(),
|
||||
categoryId: repeatableUuid(),
|
||||
brandId: repeatableUuid(),
|
||||
customerId: z.uuid().optional(),
|
||||
state: repeatableString(),
|
||||
groupBy: z.enum(REPORTING_GROUP_BY).optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z
|
||||
.coerce.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(REPORTING_PAGE_SIZE_MAX)
|
||||
.default(REPORTING_PAGE_SIZE_DEFAULT),
|
||||
sort: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
.refine((v) => new Date(v.from) < new Date(v.to), {
|
||||
message: 'from must be before to',
|
||||
})
|
||||
.transform((v): ReportingFilters => ({
|
||||
range: { from: v.from, to: v.to },
|
||||
compare: v.compare,
|
||||
channel: v.channel,
|
||||
storeIds: v.storeId ?? [],
|
||||
terminalIds: v.terminalId ?? [],
|
||||
cashierIds: v.cashierId ?? [],
|
||||
paymentMethodIds: v.paymentMethodId ?? [],
|
||||
productIds: v.productId ?? [],
|
||||
categoryIds: v.categoryId ?? [],
|
||||
brandIds: v.brandId ?? [],
|
||||
customerId: v.customerId,
|
||||
state: v.state ?? [],
|
||||
groupBy: v.groupBy,
|
||||
page: v.page,
|
||||
pageSize: v.pageSize,
|
||||
sort: v.sort,
|
||||
}));
|
||||
|
||||
export type ReportingFiltersValue = z.output<typeof reportingFiltersSchema>;
|
||||
|
||||
/** Parse + validate raw query into ReportingFilters (throws ZodError). */
|
||||
export function parseReportingFilters(query: unknown): ReportingFilters {
|
||||
return reportingFiltersSchema.parse(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous-period range for a comparison mode (`[from,to)` semantics).
|
||||
* `null` for `none`. `previous_equal` mirrors the exact duration; `previous_calendar`
|
||||
* shifts back by the calendar days spanned, UTC-aligned.
|
||||
*/
|
||||
export function comparisonRange(
|
||||
range: DateRange,
|
||||
mode: ComparisonMode,
|
||||
): ComparisonRange | null {
|
||||
if (mode === 'none') return null;
|
||||
const start = new Date(range.from).getTime();
|
||||
const end = new Date(range.to).getTime();
|
||||
const duration = end - start;
|
||||
|
||||
if (mode === 'previous_equal') {
|
||||
return {
|
||||
from: new Date(start - duration).toISOString(),
|
||||
to: new Date(start).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// previous_calendar: shift back by the whole calendar days spanned, UTC-aligned,
|
||||
// so a calendar period maps to the prior same-length calendar period.
|
||||
const spanDays = Math.max(1, Math.ceil(duration / (24 * 60 * 60 * 1000)));
|
||||
const prevStart = new Date(start - spanDays * 24 * 60 * 60 * 1000);
|
||||
prevStart.setUTCHours(0, 0, 0, 0);
|
||||
const prevEnd = new Date(prevStart);
|
||||
prevEnd.setUTCDate(prevEnd.getUTCDate() + spanDays);
|
||||
return { from: prevStart.toISOString(), to: prevEnd.toISOString() };
|
||||
}
|
||||
|
||||
/** Single source of truth for the filter metadata served to clients. */
|
||||
export const REPORTING_FILTER_META: ReportingFilterMeta = {
|
||||
filters: [
|
||||
{
|
||||
name: 'from',
|
||||
type: 'date',
|
||||
required: true,
|
||||
repeatable: false,
|
||||
description: 'Inclusive start datetime (ISO 8601 with UTC offset).',
|
||||
},
|
||||
{
|
||||
name: 'to',
|
||||
type: 'date',
|
||||
required: true,
|
||||
repeatable: false,
|
||||
description: 'Exclusive end datetime (ISO 8601 with UTC offset).',
|
||||
},
|
||||
{
|
||||
name: 'compare',
|
||||
type: 'enum',
|
||||
required: false,
|
||||
repeatable: false,
|
||||
options: [...REPORTING_COMPARISON],
|
||||
description: 'Date-comparison mode against a previous period.',
|
||||
},
|
||||
{
|
||||
name: 'channel',
|
||||
type: 'enum',
|
||||
required: false,
|
||||
repeatable: false,
|
||||
options: [...REPORTING_CHANNELS],
|
||||
description: 'Sales channel filter.',
|
||||
},
|
||||
{
|
||||
name: 'storeId',
|
||||
type: 'uuid',
|
||||
required: false,
|
||||
repeatable: true,
|
||||
description: 'One or more store UUIDs.',
|
||||
},
|
||||
{
|
||||
name: 'terminalId',
|
||||
type: 'uuid',
|
||||
required: false,
|
||||
repeatable: true,
|
||||
description: 'One or more POS terminal UUIDs.',
|
||||
},
|
||||
{
|
||||
name: 'cashierId',
|
||||
type: 'uuid',
|
||||
required: false,
|
||||
repeatable: true,
|
||||
description: 'Backoffice user (cashier) UUIDs.',
|
||||
},
|
||||
{
|
||||
name: 'paymentMethodId',
|
||||
type: 'uuid',
|
||||
required: false,
|
||||
repeatable: true,
|
||||
description: 'Payment method UUIDs.',
|
||||
},
|
||||
{
|
||||
name: 'productId',
|
||||
type: 'uuid',
|
||||
required: false,
|
||||
repeatable: true,
|
||||
description: 'Catalog product UUIDs.',
|
||||
},
|
||||
{
|
||||
name: 'categoryId',
|
||||
type: 'uuid',
|
||||
required: false,
|
||||
repeatable: true,
|
||||
description: 'Category UUIDs (resolved against the current catalog).',
|
||||
},
|
||||
{
|
||||
name: 'brandId',
|
||||
type: 'uuid',
|
||||
required: false,
|
||||
repeatable: true,
|
||||
description: 'Brand UUIDs.',
|
||||
},
|
||||
{
|
||||
name: 'customerId',
|
||||
type: 'uuid',
|
||||
required: false,
|
||||
repeatable: false,
|
||||
description: 'Single customer UUID (walk-in POS has no customer).',
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
type: 'string',
|
||||
required: false,
|
||||
repeatable: true,
|
||||
options: [
|
||||
'PAID',
|
||||
'PROCESSING',
|
||||
'SHIPPED',
|
||||
'DELIVERED',
|
||||
'COMPLETED',
|
||||
'PARTIALLY_REFUNDED',
|
||||
'REFUNDED',
|
||||
'CANCELLED',
|
||||
],
|
||||
description: 'Order states to include.',
|
||||
},
|
||||
{
|
||||
name: 'groupBy',
|
||||
type: 'enum',
|
||||
required: false,
|
||||
repeatable: false,
|
||||
options: [...REPORTING_GROUP_BY],
|
||||
description: 'Dimension to group report rows by.',
|
||||
},
|
||||
{
|
||||
name: 'page',
|
||||
type: 'string',
|
||||
required: false,
|
||||
repeatable: false,
|
||||
description: '1-indexed page number (integer).',
|
||||
},
|
||||
{
|
||||
name: 'pageSize',
|
||||
type: 'string',
|
||||
required: false,
|
||||
repeatable: false,
|
||||
description: `Page size (1..${REPORTING_PAGE_SIZE_MAX}).`,
|
||||
},
|
||||
{
|
||||
name: 'sort',
|
||||
type: 'string',
|
||||
required: false,
|
||||
repeatable: false,
|
||||
description: 'Sort expression, e.g. -revenue.',
|
||||
},
|
||||
],
|
||||
comparison: {
|
||||
modes: [...REPORTING_COMPARISON],
|
||||
rangeBounds: 'inclusive_start_exclusive_end',
|
||||
},
|
||||
groupBy: [...REPORTING_GROUP_BY],
|
||||
pagination: {
|
||||
pageMin: 1,
|
||||
pageSizeMin: 1,
|
||||
pageSizeMax: REPORTING_PAGE_SIZE_MAX,
|
||||
},
|
||||
// F-142 §4 baseline. Future reports flip these to 'available' as the model supports them.
|
||||
dataAvailability: {
|
||||
grossSales: 'available',
|
||||
netSales: 'unavailable',
|
||||
discounts: 'available',
|
||||
tax: 'available',
|
||||
unitsSold: 'available',
|
||||
orders: 'available',
|
||||
customers: 'available',
|
||||
margin: 'unavailable',
|
||||
paymentMethod: 'unavailable',
|
||||
refunds: 'unavailable',
|
||||
shipping: 'unavailable',
|
||||
},
|
||||
};
|
||||
103
project/src/modules/reporting/domain/filters.ts
Normal file
103
project/src/modules/reporting/domain/filters.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Reporting filter contract — pure TypeScript domain types (F-143).
|
||||
* No third-party imports, no module dependencies. Only the shared reporting
|
||||
* vocabulary consumed by the application layer and the route layer.
|
||||
*/
|
||||
|
||||
/** Date-comparison modes for the `compare` query param. */
|
||||
export const REPORTING_COMPARISON = ['none', 'previous_equal', 'previous_calendar'] as const;
|
||||
export type ComparisonMode = (typeof REPORTING_COMPARISON)[number];
|
||||
|
||||
/** Sales channels a report can be scoped to. */
|
||||
export const REPORTING_CHANNELS = ['all', 'ecommerce', 'pos', 'admin'] as const;
|
||||
export type ReportingChannel = (typeof REPORTING_CHANNELS)[number];
|
||||
|
||||
/** Dimensions a report can be grouped by. */
|
||||
export const REPORTING_GROUP_BY = [
|
||||
'day',
|
||||
'week',
|
||||
'month',
|
||||
'hour',
|
||||
'store',
|
||||
'channel',
|
||||
'terminal',
|
||||
'cashier',
|
||||
'payment',
|
||||
] as const;
|
||||
export type GroupBy = (typeof REPORTING_GROUP_BY)[number];
|
||||
|
||||
/** Inclusive-start, exclusive-end date window. `[from, to)` avoids double counting. */
|
||||
export interface DateRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** Previous-period range produced by a comparison mode. `null` for `none`. */
|
||||
export interface ComparisonRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** Availability of a metric under the current data model (F-142 §4 baseline). */
|
||||
export type Availability = 'available' | 'unavailable';
|
||||
|
||||
/** Metadata describing a single filter field, for client-side UI generation. */
|
||||
export interface FilterFieldMeta {
|
||||
name: string;
|
||||
type: 'uuid' | 'string' | 'enum' | 'date';
|
||||
required: boolean;
|
||||
repeatable: boolean;
|
||||
options?: readonly string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Normalized reporting filters after parsing + validation. */
|
||||
export interface ReportingFilters {
|
||||
range: DateRange;
|
||||
compare: ComparisonMode;
|
||||
channel: ReportingChannel;
|
||||
storeIds: string[];
|
||||
terminalIds: string[];
|
||||
cashierIds: string[];
|
||||
paymentMethodIds: string[];
|
||||
productIds: string[];
|
||||
categoryIds: string[];
|
||||
brandIds: string[];
|
||||
customerId: string | undefined;
|
||||
state: string[];
|
||||
groupBy: GroupBy | undefined;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sort: string | undefined;
|
||||
}
|
||||
|
||||
/** Which metrics can be computed from the current model. */
|
||||
export interface DataAvailability {
|
||||
grossSales: Availability;
|
||||
netSales: Availability;
|
||||
discounts: Availability;
|
||||
tax: Availability;
|
||||
unitsSold: Availability;
|
||||
orders: Availability;
|
||||
customers: Availability;
|
||||
margin: Availability;
|
||||
paymentMethod: Availability;
|
||||
refunds: Availability;
|
||||
shipping: Availability;
|
||||
}
|
||||
|
||||
/** The shared metadata block returned by `GET /reporting/filters/schema`. */
|
||||
export interface ReportingFilterMeta {
|
||||
filters: FilterFieldMeta[];
|
||||
comparison: {
|
||||
modes: ComparisonMode[];
|
||||
rangeBounds: 'inclusive_start_exclusive_end';
|
||||
};
|
||||
groupBy: GroupBy[];
|
||||
pagination: {
|
||||
pageMin: number;
|
||||
pageSizeMin: number;
|
||||
pageSizeMax: number;
|
||||
};
|
||||
dataAvailability: DataAvailability;
|
||||
}
|
||||
72
project/src/modules/reporting/domain/permissions.ts
Normal file
72
project/src/modules/reporting/domain/permissions.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Reporting RBAC (F-142 §9).
|
||||
*
|
||||
* The codebase has no permission table, so permissions are a role -> permission
|
||||
* map. The `requireReportingPermission(user, perm)` call-site signature is the
|
||||
* single seam a future `backoffice_permissions` migration must preserve.
|
||||
*/
|
||||
import type { CurrentUser, Role } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
|
||||
export type ReportingPermission =
|
||||
| 'REPORTING_VIEW'
|
||||
| 'REPORTING_SALES'
|
||||
| 'REPORTING_PRODUCTS'
|
||||
| 'REPORTING_CUSTOMERS'
|
||||
| 'REPORTING_INVENTORY'
|
||||
| 'REPORTING_PAYMENTS'
|
||||
| 'REPORTING_CASH'
|
||||
| 'REPORTING_DISCOUNTS'
|
||||
| 'REPORTING_REFUNDS'
|
||||
| 'REPORTING_TAXES'
|
||||
| 'REPORTING_FINANCIAL'
|
||||
| 'REPORTING_EXPORT'
|
||||
| 'REPORTING_ADMIN';
|
||||
|
||||
/** Permissions granted to every authenticated report reader. */
|
||||
const REPORTING_VIEW_BASE: ReportingPermission[] = [
|
||||
'REPORTING_VIEW',
|
||||
'REPORTING_SALES',
|
||||
'REPORTING_PRODUCTS',
|
||||
'REPORTING_CUSTOMERS',
|
||||
'REPORTING_INVENTORY',
|
||||
'REPORTING_DISCOUNTS',
|
||||
'REPORTING_REFUNDS',
|
||||
'REPORTING_TAXES',
|
||||
];
|
||||
|
||||
/** Role -> granted reporting permissions ("compatibilidad inicial", F-142 §9). */
|
||||
export const REPORTING_ROLE_PERMISSIONS: Record<Role, ReportingPermission[]> = {
|
||||
admin: [
|
||||
...REPORTING_VIEW_BASE,
|
||||
'REPORTING_PAYMENTS',
|
||||
'REPORTING_CASH',
|
||||
'REPORTING_FINANCIAL',
|
||||
'REPORTING_EXPORT',
|
||||
'REPORTING_ADMIN',
|
||||
],
|
||||
editor: REPORTING_VIEW_BASE,
|
||||
pos_manager: [
|
||||
'REPORTING_VIEW',
|
||||
'REPORTING_SALES',
|
||||
'REPORTING_PAYMENTS',
|
||||
'REPORTING_CASH',
|
||||
],
|
||||
pos_cashier: ['REPORTING_VIEW', 'REPORTING_SALES'],
|
||||
customer: [],
|
||||
};
|
||||
|
||||
/** Permissions granted to a current user under the reporting RBAC matrix. */
|
||||
export function userReportingPermissions(user: CurrentUser): ReportingPermission[] {
|
||||
return REPORTING_ROLE_PERMISSIONS[user.role] ?? [];
|
||||
}
|
||||
|
||||
/** Throws AppError(403) unless the user holds the given reporting permission. */
|
||||
export function requireReportingPermission(
|
||||
user: CurrentUser,
|
||||
permission: ReportingPermission,
|
||||
): void {
|
||||
if (!userReportingPermissions(user).includes(permission)) {
|
||||
throw new AppError(403, 'FORBIDDEN', 'Insufficient reporting permission');
|
||||
}
|
||||
}
|
||||
32
project/src/modules/reporting/index.ts
Normal file
32
project/src/modules/reporting/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Public surface for the reporting module (F-143).
|
||||
* Re-exports the routes registrar and the contract/permission helpers so the
|
||||
* composition root (build-app.ts) and tests import only this index.
|
||||
*/
|
||||
export { registerReportingRoutes } from './api/reporting.routes.js';
|
||||
export type { ReportingRoutesDeps } from './api/reporting.routes.js';
|
||||
export {
|
||||
REPORTING_FILTER_META,
|
||||
comparisonRange,
|
||||
parseReportingFilters,
|
||||
reportingFiltersSchema,
|
||||
REPORTING_PAGE_SIZE_DEFAULT,
|
||||
REPORTING_PAGE_SIZE_MAX,
|
||||
} from './application/filters.js';
|
||||
export { REPORTING_ROLE_PERMISSIONS, requireReportingPermission, userReportingPermissions } from './domain/permissions.js';
|
||||
export type { ReportingPermission } from './domain/permissions.js';
|
||||
export {
|
||||
REPORTING_CHANNELS,
|
||||
REPORTING_COMPARISON,
|
||||
REPORTING_GROUP_BY,
|
||||
type ComparisonMode,
|
||||
type ComparisonRange,
|
||||
type DateRange,
|
||||
type FilterFieldMeta,
|
||||
type GroupBy,
|
||||
type ReportingChannel,
|
||||
type ReportingFilterMeta,
|
||||
type ReportingFilters,
|
||||
type DataAvailability,
|
||||
type Availability,
|
||||
} from './domain/filters.js';
|
||||
81
project/src/modules/reporting/tests/filters.test.ts
Normal file
81
project/src/modules/reporting/tests/filters.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
REPORTING_PAGE_SIZE_MAX,
|
||||
comparisonRange,
|
||||
parseReportingFilters,
|
||||
} from '../application/filters.js';
|
||||
|
||||
const FROM = '2026-08-01T00:00:00Z';
|
||||
const TO = '2026-08-31T23:59:59Z';
|
||||
const UUID_A = '11111111-1111-4111-8111-111111111001';
|
||||
const UUID_B = '22222222-2222-4222-8222-222222222002';
|
||||
|
||||
describe('reportingFiltersSchema / parseReportingFilters', () => {
|
||||
it('parses a valid query and applies defaults (AC1)', () => {
|
||||
const f = parseReportingFilters({ from: FROM, to: TO });
|
||||
|
||||
expect(f.range).toEqual({ from: FROM, to: TO });
|
||||
expect(f.compare).toBe('none');
|
||||
expect(f.channel).toBe('all');
|
||||
expect(f.storeIds).toEqual([]);
|
||||
expect(f.page).toBe(1);
|
||||
expect(f.pageSize).toBe(50);
|
||||
});
|
||||
|
||||
it('accepts repeated and single storeId as a UUID array', () => {
|
||||
expect(parseReportingFilters({ from: FROM, to: TO, storeId: [UUID_A, UUID_B] }).storeIds).toEqual([
|
||||
UUID_A,
|
||||
UUID_B,
|
||||
]);
|
||||
expect(parseReportingFilters({ from: FROM, to: TO, storeId: UUID_A }).storeIds).toEqual([
|
||||
UUID_A,
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects inverted range (from >= to) with a validation error (AC1)', () => {
|
||||
const same = { from: FROM, to: FROM };
|
||||
expect(() => parseReportingFilters(same)).toThrow();
|
||||
expect(() => parseReportingFilters({ from: TO, to: FROM })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid UUIDs in repeatable fields (AC4)', () => {
|
||||
expect(() => parseReportingFilters({ from: FROM, to: TO, storeId: 'not-a-uuid' })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-ISO datetimes and out-of-range page sizes', () => {
|
||||
expect(() => parseReportingFilters({ from: 'nope', to: TO })).toThrow();
|
||||
expect(() => parseReportingFilters({ from: FROM, to: TO, pageSize: 9999 })).toThrow();
|
||||
});
|
||||
|
||||
it('enforces pageSize ceiling via REPORTING_PAGE_SIZE_MAX', () => {
|
||||
expect(REPORTING_PAGE_SIZE_MAX).toBe(200);
|
||||
expect(() => parseReportingFilters({ from: FROM, to: TO, pageSize: 201 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparisonRange', () => {
|
||||
it('returns null for "none"', () => {
|
||||
expect(comparisonRange({ from: FROM, to: TO }, 'none')).toBeNull();
|
||||
});
|
||||
|
||||
it('"previous_equal" mirrors the exact duration, ending at the range start (AC3)', () => {
|
||||
const prev = comparisonRange({ from: FROM, to: TO }, 'previous_equal');
|
||||
expect(prev).not.toBeNull();
|
||||
const start = new Date(FROM).getTime();
|
||||
const end = new Date(TO).getTime();
|
||||
const duration = end - start;
|
||||
expect(prev!.to).toBe(new Date(start).toISOString());
|
||||
expect(prev!.from).toBe(new Date(start - duration).toISOString());
|
||||
expect(new Date(prev!.from).getTime()).toBeLessThan(start);
|
||||
expect(new Date(prev!.to).getTime()).toBe(start);
|
||||
});
|
||||
|
||||
it('"previous_calendar" is UTC-aligned and ends at the range start (AC4)', () => {
|
||||
const prev = comparisonRange({ from: FROM, to: TO }, 'previous_calendar');
|
||||
expect(prev).not.toBeNull();
|
||||
const thisStart = new Date(FROM).getTime();
|
||||
expect(prev!.to).toBe(new Date(thisStart).toISOString());
|
||||
expect(new Date(prev!.from).getTime()).toBeLessThan(thisStart);
|
||||
expect(new Date(prev!.to).getTime()).toBe(thisStart);
|
||||
});
|
||||
});
|
||||
50
project/src/modules/reporting/tests/permissions.test.ts
Normal file
50
project/src/modules/reporting/tests/permissions.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Role } from '../../../shared/auth.js';
|
||||
import { REPORTING_ROLE_PERMISSIONS, requireReportingPermission, userReportingPermissions } from '../domain/permissions.js';
|
||||
|
||||
function user(role: Role) {
|
||||
return { id: 'u1', email: 'u@example.com', role } as const;
|
||||
}
|
||||
|
||||
describe('reporting RBAC matrix (F-142 §9)', () => {
|
||||
it('admin holds every reporting permission', () => {
|
||||
const grants = userReportingPermissions(user('admin'));
|
||||
expect(grants).toContain('REPORTING_VIEW');
|
||||
expect(grants).toContain('REPORTING_SALES');
|
||||
expect(grants).toContain('REPORTING_EXPORT');
|
||||
expect(grants).toContain('REPORTING_ADMIN');
|
||||
});
|
||||
|
||||
it('pos_cashier only sees VIEW + SALES (least privilege)', () => {
|
||||
expect(userReportingPermissions(user('pos_cashier'))).toEqual([
|
||||
'REPORTING_VIEW',
|
||||
'REPORTING_SALES',
|
||||
]);
|
||||
});
|
||||
|
||||
it('customers never get reporting permissions', () => {
|
||||
expect(userReportingPermissions(user('customer'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('requireReportingPermission throws AppError(403) for denied perms', () => {
|
||||
// customer has no grants -> SALES denied
|
||||
expect(() => requireReportingPermission(user('customer'), 'REPORTING_SALES')).toThrow();
|
||||
// editor lacks EXPORT
|
||||
expect(() => requireReportingPermission(user('editor'), 'REPORTING_EXPORT')).toThrow();
|
||||
// admin has everything -> no throw
|
||||
expect(() => requireReportingPermission(user('admin'), 'REPORTING_VIEW')).not.toThrow();
|
||||
});
|
||||
|
||||
it('REPORTING_FINANCIAL is admin-only (AC6)', () => {
|
||||
expect(() => requireReportingPermission(user('editor'), 'REPORTING_FINANCIAL')).toThrow();
|
||||
expect(() => requireReportingPermission(user('pos_manager'), 'REPORTING_FINANCIAL')).toThrow();
|
||||
expect(() => requireReportingPermission(user('pos_cashier'), 'REPORTING_FINANCIAL')).toThrow();
|
||||
expect(() => requireReportingPermission(user('admin'), 'REPORTING_FINANCIAL')).not.toThrow();
|
||||
});
|
||||
|
||||
it('the matrix covers every role (no missing keys)', () => {
|
||||
for (const role of ['admin', 'editor', 'pos_manager', 'pos_cashier', 'customer'] as const) {
|
||||
expect(REPORTING_ROLE_PERMISSIONS[role]).toBeTypeOf('object');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user