@goplusvn/core 0.1.5 → 0.1.7

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.
@@ -0,0 +1,316 @@
1
+ import type { NextResponse } from "next/server"
2
+ import { createHash } from "crypto"
3
+
4
+ import { AppError, toAppError } from "./app-error"
5
+ import { createErrorResponse } from "./error-handler"
6
+
7
+ /**
8
+ * Server-side error system for the `error_logs` table.
9
+ *
10
+ * Core ships the generic machinery; the consuming app binds it to its own
11
+ * Prisma client + `ErrorLog` model via `createServerError({ db })` (or just the
12
+ * logger via `createErrorLogger(db)`). This keeps core database-agnostic while
13
+ * every app gets the same stack-preserving, deduped, severity-bucketed logging.
14
+ *
15
+ * // app: src/lib/errors/server-error.ts
16
+ * import { createServerError } from "@goerp/core/errors"
17
+ * import { db } from "@/lib/prisma"
18
+ * export const serverError = createServerError({ db })
19
+ *
20
+ * The app's `db.errorLog` delegate must satisfy {@link ErrorLogDelegate} (the
21
+ * columns: errorId, fingerprint, code, message, detail, context, module,
22
+ * userId, url, userAgent, severity, occurrenceCount, lastSeenAt, resolved,
23
+ * createdAt).
24
+ */
25
+
26
+ export interface ServerErrorContext {
27
+ /** The request URL (used for module derivation + storage) */
28
+ url?: string | URL
29
+ /** HTTP method, prepended to module for better grouping */
30
+ method?: string
31
+ /** Authenticated user id, if known */
32
+ userId?: string
33
+ /** User agent string from request headers */
34
+ userAgent?: string
35
+ /** Free-form extra context — JSON-stringified into `context` column */
36
+ extra?: Record<string, unknown>
37
+ /** Override the module name (defaults to derived from url) */
38
+ module?: string
39
+ /** "error" | "warn" | "fatal" — defaults to "error" */
40
+ severity?: string
41
+ }
42
+
43
+ /** Minimal shape of the Prisma `errorLog` delegate the app must provide. */
44
+ export interface ErrorLogDelegate {
45
+ findFirst(args: unknown): Promise<{ id: string } | null>
46
+ update(args: unknown): Promise<unknown>
47
+ create(args: unknown): Promise<unknown>
48
+ }
49
+
50
+ export interface ErrorLoggerDb {
51
+ errorLog: ErrorLogDelegate
52
+ }
53
+
54
+ /**
55
+ * Same fingerprint formula the client POST /api/error-logs uses, so a server
56
+ * error and its client-bubbled toast collapse onto the same row.
57
+ */
58
+ function buildFingerprint(
59
+ message: string,
60
+ code: string | null,
61
+ moduleTag: string | null,
62
+ url: string | null
63
+ ): string {
64
+ let path = url || ""
65
+ try {
66
+ path = new URL(url || "", "http://x").pathname
67
+ } catch {
68
+ /* keep raw */
69
+ }
70
+ const raw = [message.slice(0, 200), code || "", moduleTag || "", path].join("|")
71
+ return createHash("sha256").update(raw).digest("hex").slice(0, 16)
72
+ }
73
+
74
+ /**
75
+ * Derive a "module" tag from a URL pathname so admin UI can group errors.
76
+ * /api/sales/orders/cml4jn3sl0 → "sales-orders"
77
+ * /api/customers/[id] → "customers"
78
+ * / → "root"
79
+ */
80
+ function deriveModule(url?: string | URL): string | null {
81
+ if (!url) return null
82
+ let pathname: string
83
+ try {
84
+ pathname = typeof url === "string" ? new URL(url, "http://x").pathname : url.pathname
85
+ } catch {
86
+ return null
87
+ }
88
+ const segs = pathname.split("/").filter(Boolean)
89
+ // Strip locale + leading "api"
90
+ const filtered = segs.filter(
91
+ (s, i) => !(i === 0 && (s === "api" || /^[a-z]{2}$/.test(s)))
92
+ )
93
+ if (filtered.length === 0) return "root"
94
+ const idLike = /^([a-z0-9]{20,}|\d+|[0-9a-f-]{20,})$/i
95
+ const out: string[] = []
96
+ for (const s of filtered) {
97
+ if (idLike.test(s)) break
98
+ out.push(s)
99
+ if (out.length === 2) break
100
+ }
101
+ return out.join("-") || filtered[0] || null
102
+ }
103
+
104
+ /**
105
+ * Pull a Prisma error code (P2002, P2025, …) out of an error if present.
106
+ * Prisma errors live under different shapes depending on adapter wrapping.
107
+ */
108
+ function extractErrorCode(err: unknown): string | null {
109
+ if (!err || typeof err !== "object") return null
110
+ const e = err as any
111
+ if (typeof e.code === "string") return e.code
112
+ if (e.cause && typeof e.cause === "object") {
113
+ if (typeof e.cause.code === "string") return e.cause.code
114
+ if (typeof e.cause.originalCode === "string") return e.cause.originalCode
115
+ }
116
+ if (e.meta?.driverAdapterError?.cause?.originalCode) {
117
+ return String(e.meta.driverAdapterError.cause.originalCode)
118
+ }
119
+ return null
120
+ }
121
+
122
+ function buildDetail(err: unknown, ctx: ServerErrorContext | undefined): string {
123
+ const lines: string[] = []
124
+ if (err instanceof Error) {
125
+ lines.push(`Message: ${err.message}`)
126
+ if (err.stack) lines.push(`\nStack Trace:\n${err.stack}`)
127
+ if ((err as any).cause) {
128
+ try {
129
+ lines.push(`\nCause: ${JSON.stringify((err as any).cause, null, 2)}`)
130
+ } catch {
131
+ lines.push(`\nCause: ${String((err as any).cause)}`)
132
+ }
133
+ }
134
+ if ((err as any).meta) {
135
+ try {
136
+ lines.push(`\nMeta: ${JSON.stringify((err as any).meta, null, 2)}`)
137
+ } catch {
138
+ /* skip */
139
+ }
140
+ }
141
+ } else {
142
+ lines.push(`Raw: ${String(err)}`)
143
+ try {
144
+ lines.push(`JSON: ${JSON.stringify(err)}`)
145
+ } catch {
146
+ /* skip */
147
+ }
148
+ }
149
+ if (ctx?.method || ctx?.url) {
150
+ lines.push(`\nRequest: ${ctx.method || "?"} ${ctx.url || ""}`)
151
+ }
152
+ if (ctx?.extra) {
153
+ try {
154
+ lines.push(`\nContext: ${JSON.stringify(ctx.extra, null, 2)}`)
155
+ } catch {
156
+ /* skip */
157
+ }
158
+ }
159
+ return lines.join("\n")
160
+ }
161
+
162
+ function extractMessage(err: unknown): string {
163
+ if (err instanceof Error) return err.message || err.name || "Error"
164
+ if (typeof err === "string") return err
165
+ try {
166
+ return JSON.stringify(err)
167
+ } catch {
168
+ return String(err)
169
+ }
170
+ }
171
+
172
+ function safeStringify(v: unknown): string {
173
+ try {
174
+ return JSON.stringify(v)
175
+ } catch {
176
+ return String(v)
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Bind the error logger to an app's Prisma client. Returns `logServerError`,
182
+ * which is fire-and-forget (never throws, never awaits in the caller's hot
183
+ * path) and dedupes onto an existing unresolved row by fingerprint.
184
+ */
185
+ export function createErrorLogger(db: ErrorLoggerDb) {
186
+ async function persistServerError(
187
+ err: unknown,
188
+ ctx?: ServerErrorContext
189
+ ): Promise<void> {
190
+ try {
191
+ const message = extractMessage(err)
192
+ if (!message) return
193
+
194
+ const code = extractErrorCode(err)
195
+ const urlStr =
196
+ typeof ctx?.url === "string"
197
+ ? ctx.url
198
+ : ctx?.url instanceof URL
199
+ ? ctx.url.toString()
200
+ : null
201
+ const moduleTag = ctx?.module ?? deriveModule(ctx?.url)
202
+ const fingerprint = buildFingerprint(message, code, moduleTag, urlStr)
203
+ const detail = buildDetail(err, ctx)
204
+
205
+ const existing = await db.errorLog.findFirst({
206
+ where: { fingerprint, resolved: false },
207
+ orderBy: { createdAt: "desc" },
208
+ select: { id: true },
209
+ })
210
+
211
+ if (existing) {
212
+ await db.errorLog.update({
213
+ where: { id: existing.id },
214
+ data: {
215
+ occurrenceCount: { increment: 1 },
216
+ lastSeenAt: new Date(),
217
+ detail,
218
+ url: urlStr || undefined,
219
+ userId: ctx?.userId || undefined,
220
+ },
221
+ })
222
+ return
223
+ }
224
+
225
+ await db.errorLog.create({
226
+ data: {
227
+ errorId: code || "SERVER",
228
+ fingerprint,
229
+ code: code || null,
230
+ message: message.slice(0, 1000),
231
+ detail,
232
+ context: ctx?.extra ? safeStringify(ctx.extra).slice(0, 5000) : null,
233
+ module: moduleTag || null,
234
+ userId: ctx?.userId || null,
235
+ url: urlStr || null,
236
+ userAgent: ctx?.userAgent || null,
237
+ severity: ctx?.severity || "error",
238
+ occurrenceCount: 1,
239
+ lastSeenAt: new Date(),
240
+ },
241
+ })
242
+ } catch (writeErr) {
243
+ console.error("[logServerError] DB write failed:", writeErr)
244
+ console.error("[logServerError] original error was:", err)
245
+ }
246
+ }
247
+
248
+ function logServerError(err: unknown, ctx?: ServerErrorContext): void {
249
+ setTimeout(() => {
250
+ void persistServerError(err, ctx).catch((inner) => {
251
+ console.error("[logServerError] failed to persist:", inner)
252
+ })
253
+ }, 0)
254
+ }
255
+
256
+ return { logServerError }
257
+ }
258
+
259
+ export type LogServerError = (err: unknown, ctx?: ServerErrorContext) => void
260
+
261
+ /**
262
+ * The canonical "catch block" helper for API routes, built around a given
263
+ * `logServerError`. Maps Prisma codes to friendly messages + stable errorId,
264
+ * PERSISTS the real stack/Prisma meta (severity = error for 5xx, warn for 4xx),
265
+ * and returns the structured JSON with an `X-Error-Id` header.
266
+ *
267
+ * } catch (error) {
268
+ * return serverError(error, request, { message: "Lỗi xử lý thao tác" })
269
+ * }
270
+ *
271
+ * Takes the logger as a dependency (rather than a db) so an app can keep its
272
+ * own `logServerError` export — used directly by api-handler/cron — as the
273
+ * single seam, and so it stays unit-testable by mocking that one function.
274
+ */
275
+ export function buildServerError(opts: {
276
+ logServerError: LogServerError
277
+ isProd?: boolean
278
+ }) {
279
+ const { logServerError } = opts
280
+ const isProd = opts.isProd ?? process.env.NODE_ENV === "production"
281
+
282
+ return function serverError(
283
+ err: unknown,
284
+ request?: { url?: string; method?: string; headers?: Headers },
285
+ o?: { message?: string; userId?: string }
286
+ ): NextResponse {
287
+ const appError = err instanceof AppError ? err : toAppError(err, o?.message)
288
+
289
+ if (!isProd) {
290
+ console.error("[serverError]", err)
291
+ }
292
+
293
+ logServerError(err, {
294
+ url: request?.url,
295
+ method: request?.method,
296
+ userId: o?.userId,
297
+ userAgent: request?.headers?.get?.("user-agent") ?? undefined,
298
+ severity: appError.statusCode >= 500 ? "error" : "warn",
299
+ extra: { errorId: appError.errorId, code: appError.code },
300
+ })
301
+
302
+ return createErrorResponse(appError)
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Convenience: bind both the logger and the catch-block helper to an app's db
308
+ * in one call (for apps that don't need a standalone `logServerError` export).
309
+ */
310
+ export function createServerError(opts: {
311
+ db: ErrorLoggerDb
312
+ isProd?: boolean
313
+ }) {
314
+ const { logServerError } = createErrorLogger(opts.db)
315
+ return buildServerError({ logServerError, isProd: opts.isProd })
316
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared download helpers for the unified export flow. Pure (no React) so they
3
+ * can be unit-tested and reused outside components.
4
+ */
5
+
6
+ /**
7
+ * Trigger a browser download for an in-memory Blob via a transient anchor.
8
+ * Revokes the object URL afterwards so it isn't leaked.
9
+ */
10
+ export function triggerBlobDownload(blob: Blob, filename: string): void {
11
+ const url = window.URL.createObjectURL(blob)
12
+ const a = document.createElement("a")
13
+ a.href = url
14
+ a.download = filename
15
+ document.body.appendChild(a)
16
+ a.click()
17
+ a.remove()
18
+ window.URL.revokeObjectURL(url)
19
+ }
20
+
21
+ /**
22
+ * Parse the download filename a server sent in `Content-Disposition`, falling
23
+ * back to `fallback` when the header is missing/unparseable. Handles both the
24
+ * RFC 5987 `filename*=UTF-8''...` form and the plain `filename="..."` form so
25
+ * the client doesn't have to hardcode names that the server already knows.
26
+ */
27
+ export function filenameFromContentDisposition(
28
+ header: string | null,
29
+ fallback: string
30
+ ): string {
31
+ if (!header) return fallback
32
+
33
+ const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)
34
+ if (encoded?.[1]) {
35
+ try {
36
+ return decodeURIComponent(encoded[1])
37
+ } catch {
38
+ // fall through to the plain form / fallback
39
+ }
40
+ }
41
+
42
+ const plain = /filename="?([^";]+)"?/i.exec(header)
43
+ if (plain?.[1]) return plain[1].trim()
44
+
45
+ return fallback
46
+ }
@@ -0,0 +1,7 @@
1
+ export { useExport } from "./use-export"
2
+ export type { ExportOptions } from "./use-export"
3
+ export {
4
+ triggerBlobDownload,
5
+ filenameFromContentDisposition,
6
+ } from "./download-file"
7
+ export { toCsv, escapeCsvField } from "./to-csv"
@@ -0,0 +1,35 @@
1
+ /**
2
+ * CSV building with RFC 4180 escaping.
3
+ *
4
+ * The crud `exportToCSV` historically joined fields with a bare comma and
5
+ * `String(v || "")`, so any value containing a comma / double-quote / newline
6
+ * (addresses, notes) silently broke column alignment, and falsy values like
7
+ * `0`/`false` came out empty. This is the corrected, shared implementation —
8
+ * crud's `exportToCSV` now delegates here.
9
+ */
10
+
11
+ /** Escape a single CSV field per RFC 4180 (quote if it holds comma/quote/newline). */
12
+ export function escapeCsvField(value: unknown): string {
13
+ const str = value === null || value === undefined ? "" : String(value)
14
+ if (/[",\n\r]/.test(str)) {
15
+ return `"${str.replace(/"/g, '""')}"`
16
+ }
17
+ return str
18
+ }
19
+
20
+ /**
21
+ * @param data rows as plain objects
22
+ * @param fields optional explicit column order (defaults to keys of the first row)
23
+ */
24
+ export function toCsv(
25
+ data: Record<string, unknown>[],
26
+ fields?: string[]
27
+ ): string {
28
+ if (data.length === 0) return ""
29
+ const keys = fields ?? Object.keys(data[0])
30
+ const header = keys.map(escapeCsvField).join(",")
31
+ const rows = data.map((row) =>
32
+ keys.map((k) => escapeCsvField(row[k])).join(",")
33
+ )
34
+ return [header, ...rows].join("\n")
35
+ }
@@ -0,0 +1,68 @@
1
+ "use client"
2
+
3
+ import { useCallback, useState } from "react"
4
+ import { toast } from "sonner"
5
+
6
+ import {
7
+ filenameFromContentDisposition,
8
+ triggerBlobDownload,
9
+ } from "./download-file"
10
+
11
+ export interface ExportOptions {
12
+ /** Fallback filename if the server doesn't send a Content-Disposition. */
13
+ filename?: string
14
+ loadingMessage?: string
15
+ successMessage?: string
16
+ errorMessage?: string
17
+ }
18
+
19
+ /**
20
+ * The single client-side export primitive. Every "Xuất Excel" button funnels
21
+ * through here: GET the server export URL, stream it to a Blob, name the file
22
+ * from the server's Content-Disposition (or a fallback), trigger the download,
23
+ * and surface consistent loading/success/error toasts.
24
+ *
25
+ * Callers build their own filtered URL (`/api/.../export?filters`) — the part
26
+ * that genuinely differs per page — and hand it to `exportFile`. This replaces
27
+ * the previous mix of `window.open` (new tab, no error handling) and bespoke
28
+ * `fetch → blob → click` copies scattered across ~20 components.
29
+ */
30
+ export function useExport() {
31
+ const [isExporting, setIsExporting] = useState(false)
32
+
33
+ const exportFile = useCallback(
34
+ async (url: string, options: ExportOptions = {}) => {
35
+ setIsExporting(true)
36
+ const toastId = toast.loading(
37
+ options.loadingMessage ?? "Đang xuất dữ liệu..."
38
+ )
39
+ try {
40
+ const res = await fetch(url)
41
+ if (!res.ok) {
42
+ throw new Error(`Export failed with status ${res.status}`)
43
+ }
44
+
45
+ const blob = await res.blob()
46
+ const filename = filenameFromContentDisposition(
47
+ res.headers.get("Content-Disposition"),
48
+ options.filename ?? "export.xlsx"
49
+ )
50
+ triggerBlobDownload(blob, filename)
51
+
52
+ toast.success(options.successMessage ?? "Đã xuất file thành công", {
53
+ id: toastId,
54
+ })
55
+ } catch (error) {
56
+ console.error("[useExport]", error)
57
+ toast.error(options.errorMessage ?? "Có lỗi xảy ra khi xuất dữ liệu", {
58
+ id: toastId,
59
+ })
60
+ } finally {
61
+ setIsExporting(false)
62
+ }
63
+ },
64
+ []
65
+ )
66
+
67
+ return { exportFile, isExporting }
68
+ }