@vouchington/utils 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,7 @@ import { createMoneyCatalog } from '@vouchington/utils/money'
9
9
  import { serializeCookie } from '@vouchington/utils/cookies'
10
10
  import { bestAcceptLanguageMatch } from '@vouchington/utils/language-tags'
11
11
  import { createMessageTranslator } from '@vouchington/utils/message-catalog'
12
+ import { createReportInputParser } from '@vouchington/utils/moderation'
12
13
  ```
13
14
 
14
15
  ## Subpaths
@@ -35,6 +36,8 @@ import { createMessageTranslator } from '@vouchington/utils/message-catalog'
35
36
  formatting, text normalization, title casing, and word/sentence helpers.
36
37
  - `gtin`, `bigint-ids`, and `validation`: GTIN predicates, canonical positive PostgreSQL bigint IDs,
37
38
  and basic email/UUID predicates.
39
+ - `moderation`: caller-configured report target, reason, identifier, note, and cross-field input
40
+ validation. Applications own catalogs, policy, error mapping, persistence, and HTTP behavior.
38
41
  - `query` and `query-string`: generic array/boolean/number parsing, bounded integer parsing, and
39
42
  query-string serialization. Cursor parsing intentionally belongs to `@vouchington/pagination`.
40
43
  - `slugs` and `urls`: ASCII-only slugification and URL/hostname primitives. URL hostname helpers
@@ -0,0 +1,34 @@
1
+ interface ReportTarget<TType extends string, TId> {
2
+ type: TType;
3
+ id: TId;
4
+ }
5
+ interface ReportDraft<TType extends string, TId, TReason extends string> {
6
+ target: ReportTarget<TType, TId>;
7
+ reason: TReason;
8
+ note: string | null;
9
+ }
10
+ export interface ReportInputConfig<TType extends string, TId, TReason extends string> {
11
+ targetTypes: readonly TType[];
12
+ reasons: readonly TReason[];
13
+ parseTargetId(value: string): TId | null;
14
+ maxNoteLength: number;
15
+ validate?(draft: ReportDraft<TType, TId, TReason>): void;
16
+ }
17
+ interface RawReportInput {
18
+ targetType?: unknown;
19
+ targetId?: unknown;
20
+ reason?: unknown;
21
+ note?: unknown;
22
+ }
23
+ export interface ReportInputParser<TType extends string, TId, TReason extends string> {
24
+ parse(raw: RawReportInput): ReportDraft<TType, TId, TReason>;
25
+ readonly targetTypes: readonly TType[];
26
+ readonly reasons: readonly TReason[];
27
+ }
28
+ export type ReportValidationCode = 'invalid_target_type' | 'invalid_target_identifier' | 'invalid_reason' | 'invalid_note' | 'note_too_long';
29
+ export declare class ReportValidationError extends Error {
30
+ readonly code: ReportValidationCode;
31
+ constructor(code: ReportValidationCode, message: string);
32
+ }
33
+ export declare function createReportInputParser<TType extends string, TId, TReason extends string>(config: ReportInputConfig<TType, TId, TReason>): ReportInputParser<TType, TId, TReason>;
34
+ export {};
@@ -0,0 +1,49 @@
1
+ export class ReportValidationError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.name = 'ReportValidationError';
6
+ this.code = code;
7
+ }
8
+ }
9
+ export function createReportInputParser(config) {
10
+ const targetTypes = readonlyCatalog(config.targetTypes, 'targetTypes');
11
+ const reasons = readonlyCatalog(config.reasons, 'reasons');
12
+ if (!Number.isSafeInteger(config.maxNoteLength) || config.maxNoteLength < 0)
13
+ throw new TypeError('maxNoteLength must be a non-negative safe integer');
14
+ return {
15
+ targetTypes,
16
+ reasons,
17
+ parse(raw) {
18
+ const targetType = enumValue(raw.targetType, targetTypes, 'invalid_target_type', 'target type');
19
+ if (typeof raw.targetId !== 'string')
20
+ invalid('invalid_target_identifier', 'target identifier');
21
+ const targetId = config.parseTargetId(raw.targetId);
22
+ if (targetId === null)
23
+ invalid('invalid_target_identifier', 'target identifier');
24
+ const reason = enumValue(raw.reason, reasons, 'invalid_reason', 'reason');
25
+ if (raw.note !== undefined && raw.note !== null && typeof raw.note !== 'string')
26
+ invalid('invalid_note', 'note');
27
+ if (typeof raw.note === 'string' && raw.note.length > config.maxNoteLength)
28
+ throw new ReportValidationError('note_too_long', 'Note is too long');
29
+ const note = typeof raw.note === 'string' ? raw.note.trim() || null : null;
30
+ const draft = { target: { type: targetType, id: targetId }, reason, note };
31
+ config.validate?.(draft);
32
+ return draft;
33
+ },
34
+ };
35
+ }
36
+ function readonlyCatalog(values, name) {
37
+ if (values.length === 0 ||
38
+ values.some((value) => typeof value !== 'string' || value.length === 0))
39
+ throw new TypeError(`${name} must contain non-empty strings`);
40
+ return Object.freeze([...new Set(values)]);
41
+ }
42
+ function enumValue(value, catalog, code, name) {
43
+ if (typeof value !== 'string' || !catalog.includes(value))
44
+ invalid(code, name);
45
+ return value;
46
+ }
47
+ function invalid(code, name) {
48
+ throw new ReportValidationError(code, `Invalid ${name}`);
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vouchington/utils",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Dependency-free runtime utilities for Vouchington-style applications.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/utils#readme",
6
6
  "bugs": {
@@ -130,6 +130,11 @@
130
130
  "import": "./dist/validation.mjs",
131
131
  "default": "./dist/validation.mjs"
132
132
  },
133
+ "./moderation": {
134
+ "types": "./dist/moderation.d.mts",
135
+ "import": "./dist/moderation.mjs",
136
+ "default": "./dist/moderation.mjs"
137
+ },
133
138
  "./async": {
134
139
  "types": "./dist/async.d.mts",
135
140
  "import": "./dist/async.mjs",