@goplusvn/core 0.1.5 → 0.1.6
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/CHANGELOG.md +49 -0
- package/PLATFORM.md +111 -0
- package/package.json +17 -2
- package/src/crud/lib/import-export-service.ts +4 -9
- package/src/errors/app-error.ts +236 -0
- package/src/errors/error-handler.ts +89 -0
- package/src/errors/index.ts +17 -0
- package/src/errors/server-error.ts +316 -0
- package/src/export/download-file.ts +46 -0
- package/src/export/index.ts +7 -0
- package/src/export/to-csv.ts +35 -0
- package/src/export/use-export.ts +68 -0
- package/src/print/direct-print-provider.tsx +333 -0
- package/src/print/index.ts +11 -0
- package/src/print/print-styles.tsx +234 -0
- package/src/repository/cas-update.ts +54 -0
- package/src/repository/index.ts +2 -0
- package/src/styles/base.css +585 -0
- package/src/styles/radix-themes.css +160 -0
- package/src/utils/cccd-parser.ts +62 -0
- package/src/utils/date-utils.ts +175 -0
- package/src/utils/fetcher.ts +46 -0
- package/src/utils/serialize.ts +47 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased — platform foundation extraction
|
|
4
|
+
|
|
5
|
+
Promoted the cross-cutting platform layer out of the Vinh Hoa reference app into
|
|
6
|
+
core so every app inherits the same design system + infrastructure and only
|
|
7
|
+
builds its own business logic. All additive — existing subpaths are unchanged.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`@goerp/core/styles/base.css`** — the canonical design-system tokens (Radix
|
|
12
|
+
scale → shadcn semantic → Plane semantic; status, charts, sidebar, scrollbar,
|
|
13
|
+
animations). Apps `@import` this from their Tailwind v4 entry stylesheet and
|
|
14
|
+
override only brand tokens (`--primary`, `--sidebar-*`). Brand default: indigo.
|
|
15
|
+
Also ships `@goerp/core/styles/radix-themes.css` (the HSL Radix scale).
|
|
16
|
+
|
|
17
|
+
- **`@goerp/core/errors`** — `AppError` + `toAppError` (maps Prisma
|
|
18
|
+
P2002/P2025/P2003/P2014 → friendly message + stable `errorId`),
|
|
19
|
+
`withErrorHandler` / `createErrorResponse` / `errorResponse`, and the
|
|
20
|
+
db-injected factories `createErrorLogger(db)` (fingerprint-deduped persistence
|
|
21
|
+
to an `error_logs` table) + `buildServerError({ logServerError })` /
|
|
22
|
+
`createServerError({ db })`. Granular client-safe paths:
|
|
23
|
+
`@goerp/core/errors/{app-error,error-handler,server-error}`.
|
|
24
|
+
|
|
25
|
+
- **`@goerp/core/export`** — `useExport()` (the unified "Xuất Excel" hook),
|
|
26
|
+
`triggerBlobDownload` / `filenameFromContentDisposition`, and `toCsv` /
|
|
27
|
+
`escapeCsvField` (RFC 4180). The crud `exportToCSV` now delegates to `toCsv`,
|
|
28
|
+
fixing unescaped commas/quotes/newlines and falsy `0`/`false`. Granular paths:
|
|
29
|
+
`@goerp/core/export/{to-csv,download-file,use-export}`.
|
|
30
|
+
|
|
31
|
+
- **`@goerp/core/print`** — `DirectPrintProvider` (document-agnostic print device
|
|
32
|
+
layer: hidden-iframe URL printing with a mobile same-tab fallback, one-page
|
|
33
|
+
sizing, loading overlay) driven by a `renderContent` render-prop, the
|
|
34
|
+
`useDirectPrint` hook, and `PrintStyles` (A4/A5 `@media print`). Granular paths:
|
|
35
|
+
`@goerp/core/print/{direct-print-provider,print-styles}`.
|
|
36
|
+
|
|
37
|
+
- **`@goerp/core/repository`** — `casUpdate` / `casUpdateById`: the compare-and-
|
|
38
|
+
swap invariant for money/stock mutations (`updateMany` on expected state,
|
|
39
|
+
throw on zero rows so an enclosing transaction rolls back).
|
|
40
|
+
|
|
41
|
+
- **`@goerp/core/utils/{serialize,fetcher,date-utils,cccd-parser}`** —
|
|
42
|
+
`serializeDecimalFields` (Prisma Decimal → string for Client Components),
|
|
43
|
+
`swrFetcher` / `swrListFetcher` / `unwrapList`, Vietnam-timezone date helpers,
|
|
44
|
+
and the CCCD (VN ID-card) QR parser.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- `exportToCSV` (crud) no longer breaks column alignment on values containing
|
|
49
|
+
commas / quotes / newlines, and no longer drops falsy `0` / `false`.
|
package/PLATFORM.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Building an app on `@goerp/core`
|
|
2
|
+
|
|
3
|
+
Core is the platform foundation: design system, layout, RBAC, CRUD engine, and
|
|
4
|
+
the cross-cutting infrastructure below. An app should only implement its own
|
|
5
|
+
business domain (entities, routes, document templates, domain services) and wire
|
|
6
|
+
the platform pieces — not re-implement them. The Vinh Hoa app is the reference
|
|
7
|
+
implementation of every pattern here.
|
|
8
|
+
|
|
9
|
+
> When you find yourself copying a util, an error helper, a CSV builder, a print
|
|
10
|
+
> mechanism, or a CAS update into an app, stop — it belongs in core. Promote it
|
|
11
|
+
> and re-export a thin shim from the app so call sites don't change.
|
|
12
|
+
|
|
13
|
+
## Design system (styles)
|
|
14
|
+
|
|
15
|
+
One import gives the whole token system; override only your brand.
|
|
16
|
+
|
|
17
|
+
```css
|
|
18
|
+
/* app: src/app/globals.css */
|
|
19
|
+
@import "tailwindcss";
|
|
20
|
+
@import "tw-animate-css";
|
|
21
|
+
@import "@goerp/core/styles/base.css"; /* Radix → shadcn → Plane tokens */
|
|
22
|
+
@source "../node_modules/@goerp/core/src/**/*.tsx"; /* scan core classes */
|
|
23
|
+
|
|
24
|
+
:root {
|
|
25
|
+
--primary: var(--violet-9); /* re-skin: brand default is indigo */
|
|
26
|
+
--sidebar-background: 262 60% 20%;
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Use semantic tokens, never the raw Tailwind palette: `text-muted-foreground` not
|
|
31
|
+
`text-slate-500`, `bg-card` not `bg-white`, `text-destructive` not `text-red-600`.
|
|
32
|
+
Editing `base.css` reskins every app at once.
|
|
33
|
+
|
|
34
|
+
## Errors
|
|
35
|
+
|
|
36
|
+
Bind the db-injected factories once, then use `serverError` in every catch block.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// app: src/lib/errors/log-server-error.ts
|
|
40
|
+
import { createErrorLogger, type ErrorLoggerDb } from "@goerp/core/errors/server-error"
|
|
41
|
+
import { db } from "@/lib/prisma"
|
|
42
|
+
export const { logServerError } = createErrorLogger(db as unknown as ErrorLoggerDb)
|
|
43
|
+
|
|
44
|
+
// app: src/lib/errors/server-error.ts
|
|
45
|
+
import { buildServerError } from "@goerp/core/errors/server-error"
|
|
46
|
+
import { logServerError } from "./log-server-error"
|
|
47
|
+
export const serverError = buildServerError({ logServerError })
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// in a route catch block
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return serverError(error, request, { message: "Lỗi xử lý thao tác" })
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The app provides an `error_logs` Prisma model matching `ErrorLogDelegate`
|
|
58
|
+
(fingerprint, occurrenceCount, lastSeenAt, severity, …) and keeps its own
|
|
59
|
+
domain error codes; core owns the machinery.
|
|
60
|
+
|
|
61
|
+
## Export
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const { exportFile, isExporting } = useExport() // client buttons
|
|
65
|
+
exportFile(`/api/customers/export?${params}`) // never window.open
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { toCsv } from "@goerp/core/export/to-csv" // server CSV routes
|
|
70
|
+
return new Response(toCsv(rows, columns), { headers: { ... } })
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Print
|
|
74
|
+
|
|
75
|
+
Core owns *how* to print; the app supplies *what* via `renderContent`.
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
// app: wrap core's provider once near the root
|
|
79
|
+
<DirectPrintProvider renderContent={(p) => <PrintContent type={p.type} data={p.data} />}>
|
|
80
|
+
{children}
|
|
81
|
+
</DirectPrintProvider>
|
|
82
|
+
|
|
83
|
+
// anywhere
|
|
84
|
+
const { triggerDirectPrint } = useDirectPrint()
|
|
85
|
+
triggerDirectPrint({ printUrl: `/${lang}/print/direct?type=invoice&id=${id}` })
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Repository / money + stock mutations
|
|
89
|
+
|
|
90
|
+
Mutate money/stock with compare-and-swap, never read-modify-write.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { casUpdateById } from "@goerp/core/repository"
|
|
94
|
+
|
|
95
|
+
// only writes if the order is still unpaid; throws otherwise → tx rolls back
|
|
96
|
+
await casUpdateById(
|
|
97
|
+
tx.salesOrder,
|
|
98
|
+
orderId,
|
|
99
|
+
{ paymentStatus: { not: "paid" } },
|
|
100
|
+
{ paymentStatus: "paid" },
|
|
101
|
+
new Error("Đơn đã được thanh toán bởi thao tác khác")
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Utils
|
|
106
|
+
|
|
107
|
+
`@goerp/core/utils` (formatCurrency, formatDate, cn, …) plus the granular:
|
|
108
|
+
`@goerp/core/utils/serialize` (`serializeDecimalFields` before returning Prisma
|
|
109
|
+
rows to Client Components), `.../fetcher` (`swrFetcher`/`swrListFetcher`),
|
|
110
|
+
`.../date-utils` (Vietnam-timezone formatting + Prisma day/month bounds),
|
|
111
|
+
`.../cccd-parser` (VN ID-card QR).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goplusvn/core",
|
|
3
3
|
"description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.6",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"src",
|
|
16
16
|
"README.md",
|
|
17
|
-
"CHANGELOG.md"
|
|
17
|
+
"CHANGELOG.md",
|
|
18
|
+
"PLATFORM.md"
|
|
18
19
|
],
|
|
19
20
|
"repository": {
|
|
20
21
|
"type": "git",
|
|
@@ -34,6 +35,20 @@
|
|
|
34
35
|
"default": "./src/*/index.ts"
|
|
35
36
|
},
|
|
36
37
|
"./assets/*": "./src/assets/*",
|
|
38
|
+
"./styles/*": "./src/styles/*",
|
|
39
|
+
"./errors/app-error": "./src/errors/app-error.ts",
|
|
40
|
+
"./errors/error-handler": "./src/errors/error-handler.ts",
|
|
41
|
+
"./errors/server-error": "./src/errors/server-error.ts",
|
|
42
|
+
"./export/to-csv": "./src/export/to-csv.ts",
|
|
43
|
+
"./export/download-file": "./src/export/download-file.ts",
|
|
44
|
+
"./export/use-export": "./src/export/use-export.ts",
|
|
45
|
+
"./print/direct-print-provider": "./src/print/direct-print-provider.tsx",
|
|
46
|
+
"./print/print-styles": "./src/print/print-styles.tsx",
|
|
47
|
+
"./utils/serialize": "./src/utils/serialize.ts",
|
|
48
|
+
"./utils/fetcher": "./src/utils/fetcher.ts",
|
|
49
|
+
"./utils/date-utils": "./src/utils/date-utils.ts",
|
|
50
|
+
"./utils/cccd-parser": "./src/utils/cccd-parser.ts",
|
|
51
|
+
"./configs/status": "./src/configs/status.ts",
|
|
37
52
|
"./package.json": "./package.json"
|
|
38
53
|
},
|
|
39
54
|
"peerDependencies": {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EntityConfig, ImportOptions, ImportResult } from "../../types";
|
|
2
|
+
import { toCsv } from "../../export/to-csv";
|
|
2
3
|
|
|
3
4
|
function getRowValue(
|
|
4
5
|
row: Record<string, unknown>,
|
|
@@ -257,15 +258,9 @@ export function exportToCSV(
|
|
|
257
258
|
data: Record<string, unknown>[],
|
|
258
259
|
fields?: string[],
|
|
259
260
|
): string {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const headers = keys.join(",");
|
|
264
|
-
const rows = data.map((row) =>
|
|
265
|
-
keys.map((key) => String(row[key] || "")).join(","),
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
return [headers, ...rows].join("\n");
|
|
261
|
+
// Delegates to the shared RFC 4180 writer so values with commas/quotes/
|
|
262
|
+
// newlines (and falsy 0/false) survive instead of breaking column alignment.
|
|
263
|
+
return toCsv(data, fields);
|
|
269
264
|
}
|
|
270
265
|
|
|
271
266
|
/**
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application Error class with structured error information.
|
|
3
|
+
* Provides error code, user-friendly message, and context for debugging.
|
|
4
|
+
*/
|
|
5
|
+
export class AppError extends Error {
|
|
6
|
+
/** Machine-readable error code (e.g. CUSTOMER_DUPLICATE_PHONE) */
|
|
7
|
+
readonly code: string
|
|
8
|
+
|
|
9
|
+
/** User-friendly message in Vietnamese */
|
|
10
|
+
readonly userMessage: string
|
|
11
|
+
|
|
12
|
+
/** HTTP status code */
|
|
13
|
+
readonly statusCode: number
|
|
14
|
+
|
|
15
|
+
/** Additional context for debugging */
|
|
16
|
+
readonly context?: Record<string, unknown>
|
|
17
|
+
|
|
18
|
+
/** HTTP Request context (URL, method, body, status) */
|
|
19
|
+
readonly requestContext?: Record<string, unknown>
|
|
20
|
+
|
|
21
|
+
/** Timestamp of the error */
|
|
22
|
+
readonly timestamp: string
|
|
23
|
+
|
|
24
|
+
/** Unique error ID for tracing */
|
|
25
|
+
readonly errorId: string
|
|
26
|
+
|
|
27
|
+
constructor(options: {
|
|
28
|
+
code: string
|
|
29
|
+
message: string
|
|
30
|
+
userMessage?: string
|
|
31
|
+
statusCode?: number
|
|
32
|
+
context?: Record<string, unknown>
|
|
33
|
+
requestContext?: Record<string, unknown>
|
|
34
|
+
cause?: unknown
|
|
35
|
+
}) {
|
|
36
|
+
super(options.message, { cause: options.cause })
|
|
37
|
+
this.name = "AppError"
|
|
38
|
+
this.code = options.code
|
|
39
|
+
this.userMessage = options.userMessage || options.message
|
|
40
|
+
this.statusCode = options.statusCode || 500
|
|
41
|
+
this.context = options.context
|
|
42
|
+
this.requestContext = options.requestContext
|
|
43
|
+
this.timestamp = new Date().toISOString()
|
|
44
|
+
this.errorId = generateErrorId()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Convert to a serializable object for API responses.
|
|
49
|
+
* Contains user-safe info only (no stack traces in production).
|
|
50
|
+
*/
|
|
51
|
+
toResponse() {
|
|
52
|
+
return {
|
|
53
|
+
error: this.userMessage,
|
|
54
|
+
code: this.code,
|
|
55
|
+
errorId: this.errorId,
|
|
56
|
+
timestamp: this.timestamp,
|
|
57
|
+
...(process.env.NODE_ENV === "development" && {
|
|
58
|
+
debug: {
|
|
59
|
+
message: this.message,
|
|
60
|
+
context: this.context,
|
|
61
|
+
requestContext: this.requestContext,
|
|
62
|
+
stack: this.stack,
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Convert to a full diagnostic string for copy/paste support.
|
|
70
|
+
*/
|
|
71
|
+
toDiagnosticString(): string {
|
|
72
|
+
const parts = [
|
|
73
|
+
`=== Thông tin lỗi ===`,
|
|
74
|
+
`Mã lỗi: ${this.code}`,
|
|
75
|
+
`ID: ${this.errorId}`,
|
|
76
|
+
`Thời gian: ${formatTimestampVN(this.timestamp)}`,
|
|
77
|
+
`Mô tả: ${this.userMessage}`,
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
if (this.context && Object.keys(this.context).length > 0) {
|
|
81
|
+
parts.push(`Chi tiết: ${JSON.stringify(this.context, null, 2)}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (this.requestContext && Object.keys(this.requestContext).length > 0) {
|
|
85
|
+
parts.push(`Request Context:\n${JSON.stringify(this.requestContext, null, 2)}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (this.message !== this.userMessage) {
|
|
89
|
+
parts.push(`Technical: ${this.message}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (this.stack) {
|
|
93
|
+
parts.push(`\nStack Trace:\n${this.stack}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return parts.join("\n")
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Convert any error to AppError.
|
|
102
|
+
* Handles Prisma errors, standard errors, and unknown errors.
|
|
103
|
+
*/
|
|
104
|
+
export function toAppError(error: unknown, fallbackUserMessage?: string): AppError {
|
|
105
|
+
// Already AppError
|
|
106
|
+
if (error instanceof AppError) return error
|
|
107
|
+
|
|
108
|
+
// Prisma known errors
|
|
109
|
+
if (isPrismaError(error)) {
|
|
110
|
+
return prismaToAppError(error)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Standard Error
|
|
114
|
+
if (error instanceof Error) {
|
|
115
|
+
return new AppError({
|
|
116
|
+
code: "SYSTEM_ERROR",
|
|
117
|
+
message: error.message,
|
|
118
|
+
userMessage: fallbackUserMessage || "Đã xảy ra lỗi hệ thống",
|
|
119
|
+
statusCode: 500,
|
|
120
|
+
cause: error,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Unknown
|
|
125
|
+
return new AppError({
|
|
126
|
+
code: "UNKNOWN_ERROR",
|
|
127
|
+
message: String(error),
|
|
128
|
+
userMessage: fallbackUserMessage || "Đã xảy ra lỗi không xác định",
|
|
129
|
+
statusCode: 500,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// Prisma Error Handling
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
interface PrismaError {
|
|
138
|
+
code: string
|
|
139
|
+
message: string
|
|
140
|
+
meta?: { target?: string[]; modelName?: string; cause?: string }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isPrismaError(error: unknown): error is PrismaError {
|
|
144
|
+
return (
|
|
145
|
+
typeof error === "object" &&
|
|
146
|
+
error !== null &&
|
|
147
|
+
"code" in error &&
|
|
148
|
+
typeof (error as any).code === "string" &&
|
|
149
|
+
(error as any).code.startsWith("P")
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function prismaToAppError(error: PrismaError): AppError {
|
|
154
|
+
const target = error.meta?.target?.join(", ") || ""
|
|
155
|
+
const model = error.meta?.modelName || ""
|
|
156
|
+
|
|
157
|
+
switch (error.code) {
|
|
158
|
+
case "P2002": // Unique constraint violation
|
|
159
|
+
return new AppError({
|
|
160
|
+
code: "VALIDATION_DUPLICATE",
|
|
161
|
+
message: `Duplicate value for ${target} in ${model}`,
|
|
162
|
+
userMessage: `Giá trị đã tồn tại${target ? ` (${target})` : ""}. Vui lòng sử dụng giá trị khác.`,
|
|
163
|
+
statusCode: 409,
|
|
164
|
+
context: { prismaCode: error.code, target, model },
|
|
165
|
+
cause: error,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
case "P2025": // Record not found
|
|
169
|
+
return new AppError({
|
|
170
|
+
code: "NOT_FOUND",
|
|
171
|
+
message: `Record not found in ${model}`,
|
|
172
|
+
userMessage: `Không tìm thấy dữ liệu${model ? ` (${model})` : ""}`,
|
|
173
|
+
statusCode: 404,
|
|
174
|
+
context: { prismaCode: error.code, model },
|
|
175
|
+
cause: error,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
case "P2003": // Foreign key constraint violation
|
|
179
|
+
return new AppError({
|
|
180
|
+
code: "VALIDATION_REFERENCE",
|
|
181
|
+
message: `Foreign key constraint failed on ${target}`,
|
|
182
|
+
userMessage: `Không thể xóa vì có dữ liệu liên quan`,
|
|
183
|
+
statusCode: 409,
|
|
184
|
+
context: { prismaCode: error.code, target, model },
|
|
185
|
+
cause: error,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
case "P2014": // Required relation violation
|
|
189
|
+
return new AppError({
|
|
190
|
+
code: "VALIDATION_RELATION",
|
|
191
|
+
message: `Required relation violation in ${model}`,
|
|
192
|
+
userMessage: `Thiếu dữ liệu bắt buộc liên quan`,
|
|
193
|
+
statusCode: 400,
|
|
194
|
+
context: { prismaCode: error.code, model },
|
|
195
|
+
cause: error,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
default:
|
|
199
|
+
return new AppError({
|
|
200
|
+
code: `DATABASE_${error.code}`,
|
|
201
|
+
message: error.message,
|
|
202
|
+
userMessage: "Lỗi cơ sở dữ liệu. Vui lòng thử lại.",
|
|
203
|
+
statusCode: 500,
|
|
204
|
+
context: { prismaCode: error.code, model, cause: error.meta?.cause },
|
|
205
|
+
cause: error,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ============================================================================
|
|
211
|
+
// Helpers
|
|
212
|
+
// ============================================================================
|
|
213
|
+
|
|
214
|
+
function generateErrorId(): string {
|
|
215
|
+
// Short 8 char ID: timestamp base36 + random
|
|
216
|
+
const ts = Date.now().toString(36).slice(-4)
|
|
217
|
+
const rand = Math.random().toString(36).slice(2, 6)
|
|
218
|
+
return `${ts}${rand}`.toUpperCase()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function formatTimestampVN(isoString: string): string {
|
|
222
|
+
try {
|
|
223
|
+
const date = new Date(isoString)
|
|
224
|
+
return date.toLocaleString("vi-VN", {
|
|
225
|
+
timeZone: "Asia/Ho_Chi_Minh",
|
|
226
|
+
day: "2-digit",
|
|
227
|
+
month: "2-digit",
|
|
228
|
+
year: "numeric",
|
|
229
|
+
hour: "2-digit",
|
|
230
|
+
minute: "2-digit",
|
|
231
|
+
second: "2-digit",
|
|
232
|
+
})
|
|
233
|
+
} catch {
|
|
234
|
+
return isoString
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { NextResponse } from "next/server"
|
|
2
|
+
import type { NextRequest } from "next/server"
|
|
3
|
+
|
|
4
|
+
import { AppError, toAppError } from "./app-error"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wrap an API route handler with centralized error handling.
|
|
8
|
+
* Catches all errors and returns structured JSON responses.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* export async function POST(req: NextRequest) {
|
|
12
|
+
* return withErrorHandler(async () => {
|
|
13
|
+
* // your logic here
|
|
14
|
+
* return NextResponse.json({ data: result })
|
|
15
|
+
* }, { operation: "createCustomer" })
|
|
16
|
+
* }
|
|
17
|
+
*/
|
|
18
|
+
export async function withErrorHandler(
|
|
19
|
+
handler: () => Promise<Response>,
|
|
20
|
+
options?: { operation?: string; req?: NextRequest | Request }
|
|
21
|
+
): Promise<Response> {
|
|
22
|
+
try {
|
|
23
|
+
return await handler()
|
|
24
|
+
} catch (error) {
|
|
25
|
+
const appError = toAppError(error)
|
|
26
|
+
|
|
27
|
+
const requestContext = options?.req
|
|
28
|
+
? {
|
|
29
|
+
url: options.req.url,
|
|
30
|
+
method: options.req.method,
|
|
31
|
+
}
|
|
32
|
+
: undefined
|
|
33
|
+
|
|
34
|
+
// If the error doesn't already have a requestContext, attach it from the request
|
|
35
|
+
if (requestContext && !appError.requestContext) {
|
|
36
|
+
Object.assign(appError, { requestContext })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Dev/terminal visibility. The persisted error_logs row (via serverError)
|
|
40
|
+
// is the durable record; this is just the in-process trace.
|
|
41
|
+
console.error(
|
|
42
|
+
`[${options?.operation || "API"}] ${appError.code}: ${appError.message}`,
|
|
43
|
+
{
|
|
44
|
+
errorId: appError.errorId,
|
|
45
|
+
code: appError.code,
|
|
46
|
+
statusCode: appError.statusCode,
|
|
47
|
+
context: appError.context,
|
|
48
|
+
requestContext: appError.requestContext,
|
|
49
|
+
operation: options?.operation,
|
|
50
|
+
stack: appError.stack,
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return createErrorResponse(appError)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create a structured error response from an AppError.
|
|
60
|
+
*/
|
|
61
|
+
export function createErrorResponse(error: AppError): NextResponse {
|
|
62
|
+
return NextResponse.json(error.toResponse(), {
|
|
63
|
+
status: error.statusCode,
|
|
64
|
+
headers: {
|
|
65
|
+
"X-Error-Id": error.errorId,
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create an AppError and return as response.
|
|
72
|
+
* Convenience for direct use in routes without wrapping.
|
|
73
|
+
*/
|
|
74
|
+
export function errorResponse(
|
|
75
|
+
code: string,
|
|
76
|
+
userMessage: string,
|
|
77
|
+
statusCode: number = 500,
|
|
78
|
+
options?: { message?: string; context?: Record<string, unknown> }
|
|
79
|
+
): NextResponse {
|
|
80
|
+
const error = new AppError({
|
|
81
|
+
code,
|
|
82
|
+
message: options?.message || userMessage,
|
|
83
|
+
userMessage,
|
|
84
|
+
statusCode,
|
|
85
|
+
context: options?.context,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
return createErrorResponse(error)
|
|
89
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { AppError, toAppError } from "./app-error"
|
|
2
|
+
export {
|
|
3
|
+
withErrorHandler,
|
|
4
|
+
createErrorResponse,
|
|
5
|
+
errorResponse,
|
|
6
|
+
} from "./error-handler"
|
|
7
|
+
export {
|
|
8
|
+
createErrorLogger,
|
|
9
|
+
buildServerError,
|
|
10
|
+
createServerError,
|
|
11
|
+
} from "./server-error"
|
|
12
|
+
export type {
|
|
13
|
+
ServerErrorContext,
|
|
14
|
+
ErrorLogDelegate,
|
|
15
|
+
ErrorLoggerDb,
|
|
16
|
+
LogServerError,
|
|
17
|
+
} from "./server-error"
|