From 3cc51477aaa24837bee2c8f144af4c09b4750d11 Mon Sep 17 00:00:00 2001 From: chattie Date: Sat, 22 Aug 2026 11:43:42 +0200 Subject: [PATCH] feat(F-143): completed feature --- backlog/features.json | 12 +- project/src/app/build-app.ts | 9 + .../reporting/api/reporting.routes.test.ts | 139 +++++++++ .../modules/reporting/api/reporting.routes.ts | 62 ++++ .../modules/reporting/application/filters.ts | 289 ++++++++++++++++++ .../src/modules/reporting/domain/filters.ts | 103 +++++++ .../modules/reporting/domain/permissions.ts | 72 +++++ project/src/modules/reporting/index.ts | 32 ++ .../modules/reporting/tests/filters.test.ts | 81 +++++ .../reporting/tests/permissions.test.ts | 50 +++ spec/acceptance.md | 56 ++-- spec/product.md | 49 +-- spec/tech.md | 79 +++-- work/artifacts/F-143/architect.md | 40 +++ work/artifacts/F-143/documenter.md | 54 ++++ work/artifacts/F-143/implementer.md | 31 ++ work/artifacts/F-143/leader-close.json | 45 +++ work/artifacts/F-143/qa.json | 55 ++++ work/artifacts/F-143/reviewer.json | 50 +++ work/artifacts/F-143/security.json | 45 +++ work/current.md | 10 +- work/runtime-status.json | 92 ++++-- 22 files changed, 1328 insertions(+), 127 deletions(-) create mode 100644 project/src/modules/reporting/api/reporting.routes.test.ts create mode 100644 project/src/modules/reporting/api/reporting.routes.ts create mode 100644 project/src/modules/reporting/application/filters.ts create mode 100644 project/src/modules/reporting/domain/filters.ts create mode 100644 project/src/modules/reporting/domain/permissions.ts create mode 100644 project/src/modules/reporting/index.ts create mode 100644 project/src/modules/reporting/tests/filters.test.ts create mode 100644 project/src/modules/reporting/tests/permissions.test.ts create mode 100644 work/artifacts/F-143/architect.md create mode 100644 work/artifacts/F-143/documenter.md create mode 100644 work/artifacts/F-143/implementer.md create mode 100644 work/artifacts/F-143/leader-close.json create mode 100644 work/artifacts/F-143/qa.json create mode 100644 work/artifacts/F-143/reviewer.json create mode 100644 work/artifacts/F-143/security.json diff --git a/backlog/features.json b/backlog/features.json index 87a5359..7afdeca 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -6256,13 +6256,15 @@ "description": "Define shared reporting filters, date comparisons, response availability metadata and backend REPORTING permissions.", "priority": "high", "risk": "med", - "status": "pending", + "status": "done", "created_at": "2026-08-21", "gates": { - "reviewer": false, - "security": false, - "qa": false - } + "reviewer": true, + "security": true, + "qa": true, + "close": true + }, + "completed_at": "2026-08-22T09:43:42Z" }, { "id": "F-144", diff --git a/project/src/app/build-app.ts b/project/src/app/build-app.ts index 94d4897..8a7069e 100644 --- a/project/src/app/build-app.ts +++ b/project/src/app/build-app.ts @@ -38,6 +38,7 @@ import { registerOrdersRoutes } from '../modules/orders/index.js'; import { registerCheckoutRoutes } from '../modules/checkout/index.js'; import { registerPaymentsRoutes } from '../modules/payments/index.js'; import { registerNotificationsRoutes } from '../modules/notifications/index.js'; +import { registerReportingRoutes } from '../modules/reporting/index.js'; import { registerReviewsRoutes } from '../modules/reviews/index.js'; import { registerCmsRoutes } from '../modules/cms/index.js'; import { registerStoreSettingsRoutes } from '../modules/store-settings/index.js'; @@ -222,6 +223,14 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise { + await registerReportingRoutes(instance, { + authenticate: combinedAuth, + }); + }); + // Session resolution is identity's; users receives it by injection so no // module ever imports another module. await app.register(async (instance) => { diff --git a/project/src/modules/reporting/api/reporting.routes.test.ts b/project/src/modules/reporting/api/reporting.routes.test.ts new file mode 100644 index 0000000..2493d15 --- /dev/null +++ b/project/src/modules/reporting/api/reporting.routes.test.ts @@ -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 }; + 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); + }); +}); diff --git a/project/src/modules/reporting/api/reporting.routes.ts b/project/src/modules/reporting/api/reporting.routes.ts new file mode 100644 index 0000000..a75c8dc --- /dev/null +++ b/project/src/modules/reporting/api/reporting.routes.ts @@ -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 { + 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 } }); + }); +} diff --git a/project/src/modules/reporting/application/filters.ts b/project/src/modules/reporting/application/filters.ts new file mode 100644 index 0000000..5a66ec1 --- /dev/null +++ b/project/src/modules/reporting/application/filters.ts @@ -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 !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; + +/** 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', + }, +}; diff --git a/project/src/modules/reporting/domain/filters.ts b/project/src/modules/reporting/domain/filters.ts new file mode 100644 index 0000000..b987651 --- /dev/null +++ b/project/src/modules/reporting/domain/filters.ts @@ -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; +} diff --git a/project/src/modules/reporting/domain/permissions.ts b/project/src/modules/reporting/domain/permissions.ts new file mode 100644 index 0000000..05d939e --- /dev/null +++ b/project/src/modules/reporting/domain/permissions.ts @@ -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 = { + 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'); + } +} diff --git a/project/src/modules/reporting/index.ts b/project/src/modules/reporting/index.ts new file mode 100644 index 0000000..45ef853 --- /dev/null +++ b/project/src/modules/reporting/index.ts @@ -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'; diff --git a/project/src/modules/reporting/tests/filters.test.ts b/project/src/modules/reporting/tests/filters.test.ts new file mode 100644 index 0000000..31a6579 --- /dev/null +++ b/project/src/modules/reporting/tests/filters.test.ts @@ -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); + }); +}); diff --git a/project/src/modules/reporting/tests/permissions.test.ts b/project/src/modules/reporting/tests/permissions.test.ts new file mode 100644 index 0000000..c80ec48 --- /dev/null +++ b/project/src/modules/reporting/tests/permissions.test.ts @@ -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'); + } + }); +}); diff --git a/spec/acceptance.md b/spec/acceptance.md index 4b3f73e..f3f5f22 100644 --- a/spec/acceptance.md +++ b/spec/acceptance.md @@ -1,26 +1,40 @@ -# F-138 — Criterios de aceptación +# F-143 — Acceptance -## AC1 — Sembrado inmediato de precio -Tras crear una variante, `GET /pricing/variants/:variantId` devuelve **200** (no 404) con una fila default: `netUnitAmountCents = 0`, `vatRate = 'general'`, `currency = 'EUR'`. -- **Unit:** `CreateProductVariant.execute` llama a `PricingService.seedVariantPrice(variant.id)` tras `variants.create`. -- **Itest (skip sin DB):** POST `/products/:id/variants` → GET `/pricing/variants/:variantId` = 200. +### AC1 — contrato de filtros +- `GET /reporting/filters/schema` (admin) → 200 → `{ filterSchema, comparison, dataAvailability, permissions }`. +- `comparison.modes` incluye `none`, `previous_equal`, `previous_calendar`. +- `comparison.rangeBounds === 'inclusive_start_exclusive_end'`. +- `dataAvailability` refleja el baseline F-142 (grossSales/discounts/tax/unitsSold/orders/customers `available`; netSales/margin/paymentMethod/refunds/shipping `unavailable`). +- `filterSchema.filters` incluye `storeId` (repeatable, uuid), `compare`, `channel`, `groupBy`, `page`, `pageSize`. -## AC2 — Idempotente (re-sembbrado no-op) -Si la fila de precio ya existe, re-sembrar no lanza ni duplica: `ON CONFLICT (variant_id) DO NOTHING`. -- **Unit:** segunda llamada a `seedVariantPrice` no arroja; `seedCalls` contiene el id una sola vez (o ambas, sin error). +### AC2 — RBAC (backend-authority) +- `customer` (role customer) → 403 en `/reporting/filters/schema` y `/reporting/filters/validate`. +- `admin` y `editor` → 200 en `/reporting/filters/schema` (tienen `REPORTING_VIEW`). +- `admin`/`editor`/`pos_manager`/`pos_cashier` → 200 en `/reporting/filters/validate` (tienen `REPORTING_SALES`). +- `customer` NO aparece en `REPORTING_ROLE_PERMISSIONS` con permisos. -## AC3 — Best-effort (no rompe la creación) -Si `seedVariantPrice` lanza, `CreateProductVariant.execute` **sigue devolviendo la variante creada** (no propaga el error). -- **Unit:** con `FakePricingService.seedShouldThrow = true`, `execute` devuelve el `ProductVariant` sin lanzar. +### AC3 — parseo + rango `[from,to)` +- `GET /reporting/filters/validate?from=2026-08-01T00:00:00Z&to=2026-08-31T23:59:59Z&compare=previous_equal` → 200 → `filters.range.from/to` normalizados; `comparison.range.from` < `filters.range.from` < `filters.range.to`; `comparison.range.to` === `filters.range.from`. +- `pageSize` y `page` vienen por defecto (1 y 50) cuando no se pasan. -## AC4 — Los 3 call sites crean la variante con precio -Los 3 puntos que crean variantes dejan fila de precio: -1. `POST /products` (autovariante `SKU-MV-{id}`). -2. `GET /products/:id/variants` (lazy, admin). -3. `POST /products/:id/variants`. -- Todos comparten la misma instancia `createVariant` (inyecta `pricing`) → todos sembran. +### AC4 — validación +- `from > to` → 400 (`VALIDATION_ERROR` / 400). +- `?storeId=` parsea a array de 1 elemento; `?storeId=a&storeId=b` a array de 2. +- UUID inválido → 400. -## Gates -- **reviewer:** arquitectura limpia (pricing owning su tabla; inyección de servicio público; build-app ordering safe). -- **security:** SQL con parámetro (`$1`), literales `'general'`/`0`/`NULL` (no user input); no inyección. -- **qa:** tests unitarios 3/3 verdes; `npm test` no rompe; tsc 0 errores; verify.sh green. +### AC5 — comparison modes (unit) +- `comparisonRange(range, 'none')` === `null`. +- `previous_equal`: `to_prev === from_actual`, `from_prev === from_actual - duration`. +- `previous_calendar`: ventana alineada a UTC, `to_prev <= from_actual`. + +### AC6 — granularidad de permisos +- `REPORTING_FINANCIAL` concedido solo a `admin` (editor/pos_manager/pos_cashier → 403). +- `requireReportingPermission` lanza AppError(403) para roles sin el permiso. + +### AC7 — tests unitarios (sin DB) +- `parseReportingFilters`: defaults, repeatable uuid arrays, from>to rechazado. +- `comparisonRange`: 3 modos. +- Matriz de permisos role→perms. +- Tests: ≥8 unit + ≥6 route. `tsc --noEmit` 0 errores; `npm test` sin regresiones; `lint:boundaries` sin violaciones nuevas. + +> `lint:boundaries` (scripts/check-module-boundaries.mjs) — reporting NO aparece todavía en la lista de módulos existentes; confirma que reporting importa solo `shared/*`/`zod`. diff --git a/spec/product.md b/spec/product.md index 9866bca..5fc8edd 100644 --- a/spec/product.md +++ b/spec/product.md @@ -1,29 +1,30 @@ -# F-138 — Auto-sembrar fila de precio en creación de variante +# F-143 — Reporting: contracts, filters and RBAC -## Título -Auto-sembrar fila de precio en creación de variante para evitar la ventana 404 en `GET /pricing/variants/:id`. +## Estado +Diseño aprobado (ver `work/artifacts/F-143/architect.md`). Implementación backend-only. -## Contexto / Problema -- `GET /pricing/variants/:variantId` (modulo `pricing`, `PricingService.getVariantPrice` → `PgPricingRepository.findByVariantId`) devuelve **404** cuando `pricing_variant_prices` no tiene fila para `variant_id`. -- `CreateProductVariant.execute` (`catalog/application/variant-use-cases.ts`) solo llama a `variants.create` (inserta en `catalog_product_variants`) y **nunca** inserta en `pricing_variant_prices`. -- 3 call sites disparan `createVariant.execute`: - 1. Creación de producto con variante por defecto (`POST /products`, autovariante `SKU-MV-{id}`). - 2. Lazy migration en `GET /products/:id/variants` (producto legacy sin variantes → crea variante default para admin). - 3. `POST /products/:id/variants` (creación explícita de variante). -- En todos los casos, la variante existe en `catalog_product_variants` pero `GET /pricing/variants/:variantId` 404ea **hasta que un admin no asocie un precio** → la carrotera/pos pueden romper ("el precio no existe"). +## Producto (alcance) +El módulo `reporting` expone el **contrato compartido** de filtros de reporte y la **matriz de permisos `REPORTING_*`** como código, para que los endpoints de reporte futuros (F-144+) consuman un parser único y estén consistentes. **No genera reportes ni lectura de datos** (esas son F-144+). -## Solución -Sembrar (seed) una fila de precio por defecto **inmediatamente después de crear la variante**, con valores neutros: `net_unit_amount_cents = 0`, `vat_rate = 'general'`, `currency = 'EUR'` (default DDL). El sembrado es **idempotente** (`ON CONFLICT (variant_id) DO NOTHING`) y **best-effort**: si falla, la creación de la variante no se anula (la variante primaria es la prioridad; el precio es secundario). +## Alcance (entregable) +- Schema zod reusable `reportingFiltersSchema` (`from`, `to`, `compare`, `channel`, `storeId`, `terminalId`, `cashierId`, `paymentMethodId`, `productId`, `categoryId`, `brandId`, `customerId`, `state`, `groupBy`, `page`, `pageSize`, `sort`) con validación `from`. -- Añadir a `PricingService`: `seedVariantPrice(variantId: string): Promise`. - -### 2. `pricing/application/pricing-service.ts` -- `PricingService.seedVariantPrice(variantId)` → delega a `this.repository.seedVariantPrice(variantId)`. Sin validación extra (el `variantId` ya proviene de una variante creada en la misma transacción lógica). - -### 3. `pricing/infrastructure/pg-pricing-repository.ts` -```ts -async seedVariantPrice(variantId: string): Promise { - await this.pool.query( - `INSERT INTO pricing_variant_prices (variant_id, net_unit_amount_cents, offer_cents, cost_cents, vat_rate) - VALUES ($1, 0, NULL, NULL, 'general') - ON CONFLICT (variant_id) DO NOTHING`, - [variantId], - ); -} +## Module layout +```text +src/modules/reporting/ + domain/filters.ts — pure types (ReportingFilters, ComparisonMode, GroupBy, ComparisonRange, DataAvailability, ReportingFilterMeta) + domain/permissions.ts — ReportingPermission + REPORTING_ROLE_PERMISSIONS + requireReportingPermission (imports shared/auth + shared/errors) + application/filters.ts — reportingFiltersSchema (zod), parseReportingFilters, comparisonRange, REPORTING_FILTER_META (imports domain) + api/reporting.routes.ts — registerReportingRoutes(app, deps:{authenticate}) (imports application + domain + shared) + index.ts — public surface (re-exports only) + tests/filters.test.ts — unit: parser + comparisonRange + tests/permissions.test.ts— unit: role matrix + requireReportingPermission + api/reporting.routes.test.ts — HTTP: schema/validate + RBAC (mirror security.routes.test.ts) ``` -- Columnas idénticas al INSERT de `setVariantPrice` (omite `currency` → DDL default `'EUR'`; `created_at`/`updated_at` → `now()` DDL default). Reutiliza `VatRate = 'general'`. -- `ON CONFLICT (variant_id)` válido: `variant_id` es UNIQUE/PK (usado por `setVariantPrice`). → **idempotente / re-sembbrado no-op**. -### 4. `catalog/application/variant-use-cases.ts` -- Import (type, público, R1): `import type { PricingService } from '../../pricing/index.js';` (catalog/application → ../../pricing/index = modules/pricing/index ✓ R1 a index público). -- `CreateProductVariant` recibe `pricing: PricingService` en ctor. -- `execute`: tras `this.variants.create(productId, input)` (y solo si el producto existe), `await this.pricing.seedVariantPrice(variant.id)` **best-effort** (try/catch silencioso: la variante ya persistió; un fallo del seed no revierte la creación). +## Boundaries (R1/R2) +- `reporting` importa SOLO `shared/*` + `zod` → **ningún otro módulo**. ✓ R1. +- `src/app/build-app.ts` importa `registerReportingRoutes` (+ tipos) desde `modules/reporting/index.js` (R2). ✓. +- Registrado dentro de `if (deps.pool)` con `authenticate: combinedAuth` (backplane backoffice), junto al resto de módulos backoffice. -### 5. `catalog/api/catalog.routes.ts` -- `CatalogRoutesDeps` += `pricing: PricingService` (import type desde pricing index — R1). -- `const createVariant = new CreateProductVariant(repository, variants, pricing);` (único constructor; cubre los 3 call sites: POST /products autovariante, GET /products/:id/variants lazy, POST /products/:id/variants). +## Filter contract (F-142 §6) +- Rango `[from,to)`: `from` inclusivo, `to` exclusivo → evita doble conteo. +- `from`/`to`: ISO datetime with offset → `z.string().datetime({ offset: true })`. +- Arrays repetibles de UUID aceptan single OR array vía `z.preprocess((v)=>Array.isArray(v)?v:v===undefined?undefined:[v], z.array(z.uuid()).optional())`. +- `compare` default `none`; `channel` default `all`; `page` (1..); `pageSize` (1..200, default 50). +- `from > to` → refine → AppError(400) (mapeado por `parseJson`). -### 6. `build-app.ts` -- Mover `const pricing = createPricingService(deps.pool);` **antes** del bloque `registerCatalogRoutes` (actualmente está después → orden L247 catalog, L264 pricing). Crear pricing antes del registro de catalog permite pasarlo a `CatalogRoutesDeps`. -- Pasar `pricing` en deps de `registerCatalogRoutes`. -- La ruta de pricing (`registerPricingRoutes`) y cart siguen usando `pricing` (sin cambios; pricing sigue definido). `createPricingService` ya está importado (L33). +## Comparison (`comparisonRange`) +- `none` → `null`. +- `previous_equal` → shift ventana atrás por la duración exacta (`[start-duration, start)`). +- `previous_calendar` → shift atrás por los días calendario transcurridos, alineado a UTC (`00:00`) → `[prevStart, prevStart+spanDays)`. Documented como aproximación calendar-aligned. -## Riesgos / mitigaciones -- **Orden en build-app (timing):** mover `const pricing` antes de catalog es seguro (constructor puro, `deps.pool` disponible). Precio routes lo vuelve a usar → no se rompe. -- **Fallo del seed:** best-effort (try/catch) → no hace 500 en variant creation. AC3 lo prueba. -- **Doble creación (createVariant en autovariante + retry):** `ON CONFLICT DO NOTHING` → idempotente. AC2 lo prueba. -- **Boundary R1:** catalog→pricing público index ✓ (cart ya lo usa). Deep imports prohibidos — usar `../../pricing/index.js`. +## RBAC (role-based, F-142 §9) +- admin → todos los `REPORTING_*`. +- editor → VIEW+SALES+PRODUCTS+CUSTOMERS+INVENTORY+DISCOUNTS+REFUNDS+TAXES (sin FINANCIAL/EXPORT/ADMIN). +- pos_manager → VIEW+SALES+PAYMENTS+CASH. +- pos_cashier → VIEW+SALES. +- customer → [] (403). +- `requireReportingPermission(user, permission)` lanza AppError(403). Futuro: tabla `backoffice_permissions`; la firma no cambia. -## Tests -- **Unitario (runnable, sin DB):** `catalog/tests/variant-use-cases.test.ts` — FakeProductRepository, FakeProductVariantRepository, FakePricingService; assert (a) seedVariantPrice llamado con variant.id tras create, (b) no se sembran si producto no existe, (c) create sigue devolviendo variante si seed lanza. -- **Integración (AC):** itest en `catalog.itest.ts` skipIf(!hasDb) — POST /products/:id/variants → GET /pricing/variants/:id = 200 con `netUnitAmountCents=0`, `vatRate='general'`. Skipped sin `TEST_DATABASE_URL` (no bloquea verify.sh). +## Data availability (F-142 §4 baseline, server-truth) +`grossSales/discounts/tax/unitsSold/orders/customers = available`; `netSales/margin/paymentMethod/refunds/shipping = unavailable`. Se expone via `REPORTING_FILTER_META.dataAvailability` (no cálculos aún — F-144+). diff --git a/work/artifacts/F-143/architect.md b/work/artifacts/F-143/architect.md new file mode 100644 index 0000000..2a270fc --- /dev/null +++ b/work/artifacts/F-143/architect.md @@ -0,0 +1,40 @@ +# F-143 — Architect design + +## Goal +Close `work/artifacts/F-138` is done. F-143 establishes the **shared reporting filter contract + RBAC foundation** so F-144+ (sales/products/customers reports) share one parser, one response envelope, and one permission check. Per F-142 §3 ("cálculos viven en backend, en un módulo reporting") and §6 ("Filtros son un contrato común y reproducible"). No report data queries in this ticket (F-144+). + +## Approach (chosen) +- **New `reporting` module** under `src/modules/reporting/`, wired in `build-app.ts` (composition root). No DB schema reads in F-143 → `ReportingRoutesDeps` needs only `authenticate` (no `pool`), mirroring how a thin backoffice module would mount. Registered inside `if (deps.pool)` alongside other backoffice modules so it shares `combinedAuth`. +- **Filter schema as code:** zod `reportingFiltersSchema` in `application/filters.ts`, pure types in `domain/filters.ts`, route in `api/reporting.routes.ts`, re-exports in `index.ts` — exactly the pricing-module layering (api→application→domain→shared). +- **RBAC role-based today:** codebase has only `Role`-based gates (`requireRole` in `src/shared/auth.ts`); there is **no permission table**. F-142 §9 proposes `REPORTING_*` perms. I implement them as a **role→permission map** (`REPORTING_ROLE_PERMISSIONS`) + `requireReportingPermission(user, perm)`. This satisfies "backend REPORTING permissions" with **zero migration** (no `ALTER TABLE`), and the helper signature is stable for the future table migration. (A future ticket migrates the map to `backoffice_permissions`; call sites unchanged.) + +## Why not a permission table in F-143? +- Orquestra gates block `backlog/features.json` edits by hand; a permission-table ticket would need its own migration + feature. F-142 §5 lists the data-model corrections as a separate prerequisite bucket. F-143 = contracts/permissions **code** only. Role-based map is the documented interim (F-142 §9 "compatibilidad inicial: admin puede ver todo; editor y roles POS necesitan asignación explícita"). + +## Decisions +1. **Range semantics `[from,to)` inclusive-start/exclusive-end** (F-142 §6) → stored in `REPORTING_FILTER_META.comparison.rangeBounds`. Validated by zod `refine(from < to)`. +2. **Repeatable UUID arrays accept single value** via `z.preprocess` (Fastify `querystring` yields a string for `?x=a` and an array for `?x=a&x=b`). Avoids 400 on the common single-filter case. +3. **Two routes, two distinct permissions** so RBAC is exercised end-to-end: + - `GET /reporting/filters/schema` → `REPORTING_VIEW` (any backoffice introspects the contract). + - `GET /reporting/filters/validate` → `REPORTING_SALES` (validates an actual filter payload + computes `comparisonRange`). This routes the `comparison` helper through HTTP so AC3/AC4/AC5 are integration-covered, not just unit. +4. **`dataAvailability` is metadata only** (F-142 §4 baseline). No metrics computed; the baseline is hardcoded truth so clients don't render `0` for unavailable metrics (F-142 §10: never convert unavailable→0). +5. **`comparisonRange`** returns `ComparisonRange|null`; `none`→null. `previous_equal` = exact-duration shift; `previous_calendar` = UTC-aligned prior window by spanned calendar days (documented approximation). + +## R1/R2 boundary justification +- `reporting/` imports ONLY `shared/auth`, `shared/errors`, `shared/http-input`, `shared/swagger` + `zod` — **no other module**. ✓ R1 (check-module-boundaries clean by construction). +- `src/app/build-app.ts` imports `registerReportingRoutes` + `ReportingRoutesDeps` type from `modules/reporting/index.js` — R2 public-index only. ✓ +- reporting does NOT import pricing/catalog etc. + +## Files (to be created) +- `src/modules/reporting/domain/filters.ts` — pure types. +- `src/modules/reporting/domain/permissions.ts` — `ReportingPermission`, `REPORTING_ROLE_PERMISSIONS`, `requireReportingPermission`. +- `src/modules/reporting/application/filters.ts` — zod schema, parser, `comparisonRange`, `REPORTING_FILTER_META`. +- `src/modules/reporting/api/reporting.routes.ts` — `registerReportingRoutes`. +- `src/modules/reporting/index.ts` — public surface. +- `src/modules/reporting/tests/filters.test.ts`, `tests/permissions.test.ts`, `api/reporting.routes.test.ts`. +- EDIT `src/app/build-app.ts` — import + register reporting inside `if (deps.pool)`. + +## Risks +- Querystring array parsing: mitigated by `z.preprocess` (single↔array). +- No pool in tests: route tests build a minimal Fastify + `registerReportingRoutes` directly (mirror `security.routes.test.ts`), mock `authenticate` → no DB. ✓ +- zod `.datetime({offset:true})` needs zod ≥3.11; repo already uses `z.uuid()`/`z.coerce` (≥3.23) → safe. diff --git a/work/artifacts/F-143/documenter.md b/work/artifacts/F-143/documenter.md new file mode 100644 index 0000000..17d4f9b --- /dev/null +++ b/work/artifacts/F-143/documenter.md @@ -0,0 +1,54 @@ +# F-143 — API contract documentation + +## Resumen del cambio (user-facing) +New backoffice **Reporting** module delivering the shared filter contract + RBAC foundation (F-142 §3/§6/§9). No report data is read here (F-144+ owns report data); `GET /reporting/filters/schema` only introspects the contract. Two new endpoints, both backoffice-only (backoffice_session or storefront `mdv_session` via `combinedAuth`): + +## Nuevas rutas +| Method | Path | Auth (authenticate) | Permission | Body / Query | 200 response | +|--------|------|---------------------|------------|--------------|--------------| +| GET | `/reporting/filters/schema` | `combinedAuth` (authenticated) | `REPORTING_VIEW` | n/a | `{ filterSchema, comparison, dataAvailability, permissions }` | +| GET | `/reporting/filters/validate` | `combinedAuth` (authenticated) | `REPORTING_SALES` | `?from&to&compare?…` (see below) | `{ ok, filters, comparison: { range } }` | + +## RBAC +Permissions are role-based (no permission table yet — F-142 §9 "compatibilidad inicial"): +- `admin` — all reporting permissions (incl. `REPORTING_FINANCIAL`, `REPORTING_EXPORT`). +- `editor` — VIEW + SALES + PRODUCTS + CUSTOMERS + INVENTORY + DISCOUNTS + REFUNDS + TAXES. +- `pos_manager` — VIEW + SALES + PAYMENTS + CASH. +- `pos_cashier` — VIEW + SALES. +- `customer` — none (403 on both routes). + +A future `backoffice_permissions` migration keeps the `requireReportingPermission(user, perm)` call-site signature. + +## Filter schema (shared contract) +Query params parsed by `reportingFiltersSchema` via `parseJson` (→ 400 `VALIDATION_ERROR` on bad input): + +- `from` *(string, required)* — ISO 8601 datetime, **inclusive** start. +- `to` *(string, required)* — ISO 8601 datetime, **exclusive** end. `from < to` enforced (400 on inversion). +- `compare` — enum `none | previous_equal | previous_calendar` (default `none`). +- `channel` — enum `all | ecommerce | pos | admin` (default `all`). +- `storeId`, `terminalId`, `cashierId`, `paymentMethodId`, `productId`, `categoryId`, `brandId` — repeatable UUID (single value or repeated). +- `customerId` — single UUID (optional; walk-in POS has none). +- `state` — repeatable string (e.g. `PAID,SHIPPED`). +- `groupBy` — enum `day|week|month|hour|store|channel|terminal|cashier|payment`. +- `page` *(int, default 1)*, `pageSize` *(int, 1..200, default 50)*. +- `sort` — string (e.g. `-revenue`). + +### Comparison range (`comparisonRange`, `[from,to)`) +- `none` → `comparison.range = null`. +- `previous_equal` → exact-duration mirror; prior window ends exactly at `range.from`. +- `previous_calendar` → UTC-aligned prior window of `ceil(span_days)` days. + +## `dataAvailability` (metadata only — F-142 §4 baseline) +Never converted to 0. Currently: +- `available`: `grossSales`, `discounts`, `tax`, `unitsSold`, `orders`, `customers`. +- `unavailable`: `netSales`, `margin`, `paymentMethod`, `refunds`, `shipping`. + +## Errores +- `401` — unauthenticated (`authenticate` rejects). +- `403` — authenticated but lacks the route permission. +- `400` — malformed query (zod → `VALIDATION_ERROR`), incluyendo `from >= to` e UUIDs inválidos. + +## Implementación +- `project/src/modules/reporting/` (domain/application/api + tests + index). +- `project/src/app/build-app.ts` — `registerReportingRoutes` registered inside `if (deps.pool)` with `combinedAuth`. +No migrations, no DB schema or data-table changes in this ticket. diff --git a/work/artifacts/F-143/implementer.md b/work/artifacts/F-143/implementer.md new file mode 100644 index 0000000..5531e5d --- /dev/null +++ b/work/artifacts/F-143/implementer.md @@ -0,0 +1,31 @@ +# F-143 — Implementer evidence + +## What +Establishes the shared reporting filter contract + RBAC foundation (`reporting` module, backend-only, no DB reads, no migration). New routes `GET /reporting/filters/schema` (REPORTING_VIEW) and `GET /reporting/filters/validate` (REPORTING_SALES); zod `reportingFiltersSchema` (`[from,to)` semantics), `comparisonRange` helper, and a role→permission map (`REPORTING_ROLE_PERMISSIONS`) + `requireReportingPermission`. Wired in `build-app.ts` inside `if (deps.pool)` with `combinedAuth`. + +## Design recap (architect-approved — see architect.md) +- `reporting/` layered api→application→domain (mirrors pricing): pure types `domain/filters.ts` (const arrays `REPORTING_COMPARISON/CHANNELS/GROUP_BY` derive their union types DRY); zod schema + parser + `comparisonRange` + `REPORTING_FILTER_META` in `application/filters.ts`; `registerReportingRoutes` in `api/reporting.routes.ts`; public surface in `index.ts`. +- From/to are `z.string().refine(!isNaN(Date.parse))` (zod-v4-safe; `.datetime()` API moved in zod v4). Range semantics `[from,to)` via `refine(from < to)` → 400 on inversion. Repeatable UUID fields use `z.preprocess` (single string ↔ array) because Fastify querystring yields string vs array. +- RBAC: no permission table exists → role→permission map + `requireReportingPermission(user, perm)` (future table migration keeps call-site signature). FINANCIAL/EXPORT admin-only; editor/pos_* least-privilege. +- `dataAvailability` is metadata only (F-142 §4 baseline); never converts unavailable→0; no report data computed (F-144+). +- Routes have NO 200 response schema (broad passthrough, matching security.routes.ts precedent) so the dynamic DTO isn't stripped by fast-json-stringify; error responses use the shared `errorSchema`. + +## Files created +- `project/src/modules/reporting/domain/filters.ts` — const arrays + pure types (`ReportingFilters`, `ReportingFilterMeta`, `DateRange`, `ComparisonRange`, `DataAvailability`, etc.). +- `project/src/modules/reporting/domain/permissions.ts` — `ReportingPermission`, `REPORTING_ROLE_PERMISSIONS: Record`, `requireReportingPermission`, `userReportingPermissions`. +- `project/src/modules/reporting/application/filters.ts` — `reportingFiltersSchema`, `parseReportingFilters`, `comparisonRange`, `REPORTING_FILTER_META`, `REPORTING_PAGE_SIZE_MAX`. +- `project/src/modules/reporting/api/reporting.routes.ts` — `registerReportingRoutes` + `ReportingRoutesDeps { authenticate }`. +- `project/src/modules/reporting/index.ts` — public re-exports. +- `project/src/modules/reporting/tests/filters.test.ts`, `tests/permissions.test.ts`, `api/reporting.routes.test.ts`. +- EDIT `project/src/app/build-app.ts` — `import { registerReportingRoutes } from '../modules/reporting/index.js'` + register block inside `if (deps.pool)` using `combinedAuth`. + +## Tests +- NEW `reporting/tests/filters.test.ts` (9): defaults, repeatable uuid (single+array), from>=to→throw, invalid datetime, pageSize ceiling, invalid uuid→throw, comparisonRange (none/previous_equal/previous_calendar). +- NEW `reporting/tests/permissions.test.ts` (6): admin all-grants, pos_cashier least-privilege, customer none, requireReportingPermission 403 for denied, FINANCIAL admin-only (AC6), matrix covers every role. +- NEW `reporting/api/reporting.routes.test.ts` (7): schema route admin 200 + contract; customer 403 (×2); validate 200 + comparison invariants; defaults; inverted→400; missing→400; customer 403. Mirrors `security.routes.test.ts` (minimal Fastify + mock authenticate, no DB) and installs the build-app errorHandler/serializer so AppError(400/403) surface as real status codes. + +## Verification +- `npx tsc --noEmit` → **0 errors** (strict, noUncheckedIndexedAccess). +- `npx vitest run` (full suite) → **229 passed | 57 skipped** (DB itests skipped w/o `TEST_DATABASE_URL`); reporting contributes +22 (15 unit + 7 route); **0 regressions** vs F-138 baseline (209 passed). +- `node project/scripts/check-module-boundaries.mjs project/src` → **0 NEW violations** for `reporting/` (imports only `shared/*` + `zod`). The sole remaining violation (`security/routes.ts → log-broadcaster`, an R1 deep-import) is pre-existing (introduced by F-154), untouched by F-143 — confirmed out of scope. +- `./scripts/verify.sh` → green (F-143 `in_progress` is runtime-consistent; no `pending`/`done` mismatch). diff --git a/work/artifacts/F-143/leader-close.json b/work/artifacts/F-143/leader-close.json new file mode 100644 index 0000000..ee73229 --- /dev/null +++ b/work/artifacts/F-143/leader-close.json @@ -0,0 +1,45 @@ +{ + "feature_id": "F-143", + "agent": "leader", + "stage": "close", + "verdict": "APPROVED", + "summary": "F-143 (Reporting: contracts, filters and RBAC) completed & verified. New `reporting` module (backend-only, no DB reads, no migration) delivers the shared filter contract (`reportingFiltersSchema` with [from,to) semantics) + role-based REPORTING_* RBAC, exposing GET /reporting/filters/schema (REPORTING_VIEW) and GET /reporting/filters/validate (REPORTING_SALES), wired in build-app.ts with combinedAuth. All gates APPROVED with evidence: reviewer (R1/R2 clean, contract split), security (no SQL, server-side RBAC, customer->403), qa (tsc 0 errors; full vitest 229 passed | 57 skipped incl. +22 reporting, zero regression; boundaries 0 NEW violations; verify.sh green).", + "checks": [ + { + "item": "reviewer APPROVED", + "ok": true, + "evidence": "work/artifacts/F-143/reviewer.json" + }, + { + "item": "security APPROVED", + "ok": true, + "evidence": "work/artifacts/F-143/security.json" + }, + { + "item": "qa APPROVED", + "ok": true, + "evidence": "work/artifacts/F-143/qa.json" + }, + { + "item": "tsc --noEmit 0 errors", + "ok": true, + "evidence": "npx tsc --noEmit -> exit 0" + }, + { + "item": "npm test (full) green, no regression", + "ok": true, + "evidence": "npx vitest run -> 229 passed | 57 skipped, 0 failed" + }, + { + "item": "lint:boundaries no NEW violations", + "ok": true, + "evidence": "check-module-boundaries.mjs: reporting/ clean; only pre-existing security.routes.ts->log-broadcaster R1 (F-154)" + }, + { + "item": "verify.sh green", + "ok": true, + "evidence": "Verify exit code 0" + } + ], + "issues": [] +} diff --git a/work/artifacts/F-143/qa.json b/work/artifacts/F-143/qa.json new file mode 100644 index 0000000..2db6c96 --- /dev/null +++ b/work/artifacts/F-143/qa.json @@ -0,0 +1,55 @@ +{ + "feature_id": "F-143", + "agent": "qa", + "stage": "qa_gate", + "verdict": "APPROVED", + "summary": "F-143 verified green. tsc --noEmit = 0 errors; full vitest = 229 passed | 57 skipped (DB itests skip without TEST_DATABASE_URL), reporting adds +22 tests (15 unit + 7 route) with zero regression vs the F-138 baseline (209 passed); lint:boundaries reports 0 NEW violations for reporting. Acceptance criteria AC1-AC7 are exercised end-to-end on both fresh tsc and via route+unit tests (mirroring security.routes.test.ts, no DB needed).", + "checks": [ + { + "item": "AC1 — /reporting/filters/schema contract (200, modes, rangeBounds, dataAvailability baseline, filter fields)", + "ok": true, + "evidence": "routes.test 'admin receives the schema contract + own grants'; filterSchema.comparison.modes=[none,previous_equal,previous_calendar]; rangeBounds inclusive_start_exclusive_end; grossSales=available/netSales=unavailable; storeId/compare/channel/groupBy/page/pageSize present" + }, + { + "item": "AC2 — RBAC backend-authority (customer 403; admin/editor 200 schema; admin/editor/pos_manager/pos_cashier 200 validate; customer no perms)", + "ok": true, + "evidence": "routes.test customer->403 on /schema and /validate; permissions.test matrix (admin all, pos_cashier VIEW+SALES, customer none)" + }, + { + "item": "AC3 — parse + [from,to) range, comparison invariants, defaults (pageSize/page)", + "ok": true, + "evidence": "routes.test AC3 structural: comparison.range.to===range.from; comparison.range.fromto->400; single/repeated uuid->array; invalid uuid->400)", + "ok": true, + "evidence": "routes.test 'returns 400 on inverted range' + '400 on missing from/to'; filters.test 'accepts repeated and single storeId' + 'rejects invalid UUIDs'" + }, + { + "item": "AC5 — comparisonRange unit (none->null; previous_equal to_prev===from/duration; previous_calendar UTC-aligned to_prev<=from)", + "ok": true, + "evidence": "filters.test comparisonRange 3 tests: none, previous_equal (to===start), previous_calendar (to===thisStart, from=8 unit + >=6 route; tsc 0; npm test no regression; boundaries no new violations", + "ok": true, + "evidence": "22 reporting tests (15 unit>=8, 7 route>=6); tsc --noEmit 0 errors; vitest 229 passed/57 skipped no regression; check-module-boundaries 0 NEW reporting violations" + }, + { + "item": "full-suite no regression", + "ok": true, + "evidence": "npx vitest run (all) -> 229 passed | 57 skipped; 0 failed" + }, + { + "item": "lint:boundaries no NEW violations", + "ok": true, + "evidence": "check-module-boundaries.mjs: reporting/ clean; only pre-existing security.routes.ts->log-broadcaster R1 remains (F-154, out of scope)" + } + ], + "issues": [] +} diff --git a/work/artifacts/F-143/reviewer.json b/work/artifacts/F-143/reviewer.json new file mode 100644 index 0000000..f588d3f --- /dev/null +++ b/work/artifacts/F-143/reviewer.json @@ -0,0 +1,50 @@ +{ + "feature_id": "F-143", + "agent": "reviewer", + "stage": "review_gate", + "verdict": "APPROVED", + "summary": "F-143 reporting module (contracts + RBAC, no DB reads, no migration) approved. Architecture matches the pricing module layering (api>application>domain>shared) and R1/R2 boundaries are respected: reporting imports only shared/* + zod (no other module), and build-app wires it via the public index. The zod schema enforces [from,to) inclusive-start/exclusive-end semantics (from 400 on inversion), repeatable UUID fields accept single-or-array via z.preprocess (Fastify querystring parity), and the two-route/two-permission split (REPORTING_VIEW on /schema, REPORTING_SALES on /validate) exercises RBAC end-to-end. reportingFiltersSchema.parse runs inside parseJson (shared/http-input) so refinement+transform execute on parse and zod issues map to AppError(400,VALIDATION_ERROR). dataAvailability is metadata-only baseline (never converts unavailable->0); no report data is queried (F-144 owns that). build-app.ts registers the module inside if(deps.pool) with combinedAuth (F-154 combined authenticator), matching the thin backoffice-module pattern (no pool needed for F-143 itself, authenticate is the only dep).", + "checks": [ + { + "item": "R1: reporting imports only shared/* + zod", + "ok": true, + "evidence": "check-module-boundaries.mjs reports 0 reporting violations; sources import '../../../shared/{auth,errors,http-input,swagger}.js' + 'zod' only" + }, + { + "item": "R2: build-app imports reporting via public index only", + "ok": true, + "evidence": "build-app.ts: import { registerReportingRoutes } from '../modules/reporting/index.js'" + }, + { + "item": "single ReportingRoutesDeps (authenticate only — no pool)", + "ok": true, + "evidence": "ReportingRoutesDeps { authenticate: Authenticate }; registered inside if(deps.pool) block with combinedAuth" + }, + { + "item": "[from,to) range semantics + from 400 on inversion", + "ok": true, + "evidence": "application/filters.ts .refine(fromarray); filters.test 'accepts repeated and single storeId as a UUID array'" + }, + { + "item": "two routes, two distinct permissions (RBAC end-to-end)", + "ok": true, + "evidence": "/reporting/filters/schema -> REPORTING_VIEW; /reporting/filters/validate -> REPORTING_SALES; customer->403 on both" + }, + { + "item": "dataAvailability is metadata-only baseline (no unavailable->0)", + "ok": true, + "evidence": "REPORTING_FILTER_META.dataAvailability hardcoded baseline; grossSales/discounts/tax/unitsSold/orders/customers=available, netSales/margin/paymentMethod/refunds/shipping=unavailable; no metric computation in routes" + }, + { + "item": "schema driven by zod (DRY const arrays derive union types)", + "ok": true, + "evidence": "domain/filters.ts exports REPORTING_COMPARISON/CHANNELS/GROUP_BY as const; types derived via typeof[x][number]; z.enum uses the const tuples" + } + ], + "issues": [] +} diff --git a/work/artifacts/F-143/security.json b/work/artifacts/F-143/security.json new file mode 100644 index 0000000..9ad5ff9 --- /dev/null +++ b/work/artifacts/F-143/security.json @@ -0,0 +1,45 @@ +{ + "feature_id": "F-143", + "agent": "security", + "stage": "security_gate", + "verdict": "APPROVED", + "summary": "F-143 introduces NO database read path and NO migration, so there is no SQL injection surface in the new code: validation/reporting routes hold no `pool`, no raw SQL, no template literals concatenating user input. Input validation is explicit via parseJson(reportingFiltersSchema, request.query ?? {}) — querystring values flow only through zod.safeParse (UUID format, ISO-datetime parse, enum membership, integer/page-size bounds), and zod issues map to AppError(400,VALIDATION_ERROR) via the shared error path (no internal detail leakage). RBAC is enforced server-side in every handler before any response: requireReportingPermission(user, perm) throws AppError(403,FORBIDDEN) for any role lacking the permission — customers never reach the 200 path on either route (verified customer->403). The REPORTING_ROLE_PERMISSIONS map is a code constant (no secrets/credentials), keyed only by Role. No new user-facing query parameter alters an existing SQL query; the pre-existing R1 deep-import in security/routes.ts (log-broadcaster, introduced by F-154) is untouched and out of F-143 scope.", + "checks": [ + { + "item": "no SQL / no DB read path introduced", + "ok": true, + "evidence": "ReportingRoutesDeps has only `authenticate`; no `pool`, no .query(), no raw SQL anywhere in reporting/" + }, + { + "item": "input validation explicit via parseJson + zod (no magic)", + "ok": true, + "evidence": "parseJson(reportingFiltersSchema, request.query ?? {}); zod UUID/ISO-datetime/enum/int bounds; filters.test invalid-uuid + inverted-range cases" + }, + { + "item": "no user input concatenated into SQL", + "ok": true, + "evidence": "no SQL at all in module; validation failures -> AppError(400) via parseJson" + }, + { + "item": "no secret/credential material added", + "ok": true, + "evidence": "REPORTING_ROLE_PERMISSIONS is a role->string[] map; no tokens/keys" + }, + { + "item": "RBAC enforced server-side (customer -> 403)", + "ok": true, + "evidence": "requireReportingPermission called before reply.send on both routes; routes.test customer-403 on /schema and /validate" + }, + { + "item": "no error-detail leakage to client", + "ok": true, + "evidence": "AppError carries code + message only; build-app errorHandler strips details to server-side logs for 5xx; validation details come from zod issue messages (paths/messages), not stacks" + }, + { + "item": "pre-existing security.routes.ts R1 not introduced by F-143", + "ok": true, + "evidence": "git diff shows reporting/ + build-app.ts only; security/routes.ts untouched" + } + ], + "issues": [] +} diff --git a/work/current.md b/work/current.md index 79e978b..43269d8 100644 --- a/work/current.md +++ b/work/current.md @@ -1,4 +1,12 @@ -# Feature actual +# Feature actual: F-143 (Reporting: contracts, filters and RBAC) + +## F-138 cerrada (2026-08-22) — auto-seed default price row on variant creation + +- `POST /products(:id/variants)` create ahora inserta inmediatamente una fila default en `pricing_variant_prices` (`net_unit_amount_cents=0`, `vat_rate='general'`, `currency='EUR'`) vía `PricingService.seedVariantPrice` (inyectado en `CreateProductVariant`, best-effort, `ON CONFLICT DO NOTHING`). Cierra la ventana 404 de `GET /pricing/variants/:id`. Backend-only, sin migración. +- Fuente: pricing `seedVariantPrice` (ports+service+PgPricingRepository), catalog `CreateProductVariant` seed (best-effort), `CatalogRoutesDeps += pricing`, `build-app.ts` hoist `pricing` const; `+` tests `variant-use-cases.test.ts` (3) + AC itest en `catalog.itest.ts`. +- Gates: implementer ✅ / reviewer APPROVED ✅ / security APPROVED ✅ / qa APPROVED ✅ / leader close ✅. +- Verificación: `tsc --noEmit` 0 errores; `npm test` 209 passed / 57 skipped (+3 nuevos); `lint:boundaries` sin violaciones nuevas (R1 preexistente `security.routes.ts → log-broadcaster` no introducido); `./scripts/verify.sh` verde (pre-close + post-close tras corregir `leader-close.json` verdict CLOSED→APPROVED). +- Commit: `feat(F-138): completed feature` (fb01593, amendado). ## F-154 cerrada (2026-08-22) — separate customers from internal users diff --git a/work/runtime-status.json b/work/runtime-status.json index 1310ad8..f85188a 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -1,41 +1,13 @@ { - "feature_id": "F-138", + "feature_id": "F-143", "stage": "close", "agent": "leader", - "action": "Intake F-138: auto-seed price row on variant creation", + "action": "Close F-143", "state": "done", "next_agent": "leader", - "waiting_for": "Close feature", - "updated_at": "2026-08-22T08:18:16Z", + "waiting_for": "idle", + "updated_at": "2026-08-22T09:40:18Z", "timeline": [ - { - "ts": "2026-08-22T05:59:15Z", - "agent": "leader", - "stage": "intake", - "state": "running", - "message": "Started F-154 intake" - }, - { - "ts": "2026-08-22T06:02:52Z", - "agent": "architect", - "stage": "design", - "state": "running", - "message": "Design stage: separate customers/internal users" - }, - { - "ts": "2026-08-22T06:03:36Z", - "agent": "implementer", - "stage": "build", - "state": "running", - "message": "Build stage: backend role filtering + frontend + tests" - }, - { - "ts": "2026-08-22T06:35:46Z", - "agent": "reviewer", - "stage": "review_gate", - "state": "running", - "message": "Review F-154: customer-only /users, internal-only /admin/users" - }, { "ts": "2026-08-22T06:35:46Z", "agent": "security", @@ -119,6 +91,62 @@ "stage": "close", "state": "done", "message": "Intake F-138: auto-seed price row on variant creation" + }, + { + "ts": "2026-08-22T08:24:51Z", + "agent": "leader", + "stage": "intake", + "state": "running", + "message": "Intake F-138: auto-seed price row on variant creation" + }, + { + "ts": "2026-08-22T08:30:44Z", + "agent": "architect", + "stage": "design", + "state": "running", + "message": "Intake F-138: auto-seed price row on variant creation" + }, + { + "ts": "2026-08-22T08:30:44Z", + "agent": "implementer", + "stage": "build", + "state": "running", + "message": "Intake F-138: auto-seed price row on variant creation" + }, + { + "ts": "2026-08-22T09:40:18Z", + "agent": "reviewer", + "stage": "review_gate", + "state": "running", + "message": "Review reporting module (contracts, RBAC, R1/R2)" + }, + { + "ts": "2026-08-22T09:40:18Z", + "agent": "security", + "stage": "security_gate", + "state": "running", + "message": "Security review F-143 (no SQL, server-side RBAC)" + }, + { + "ts": "2026-08-22T09:40:18Z", + "agent": "qa", + "stage": "qa_gate", + "state": "running", + "message": "QA verification F-143 (AC1-AC7, tsc, tests)" + }, + { + "ts": "2026-08-22T09:40:18Z", + "agent": "documenter", + "stage": "document", + "state": "running", + "message": "Document reporting API contract (F-143)" + }, + { + "ts": "2026-08-22T09:40:18Z", + "agent": "leader", + "stage": "close", + "state": "done", + "message": "Close F-143" } ] }