lapeeh 1.0.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/.env.example +14 -0
- package/LICENSE +21 -0
- package/bin/index.js +934 -0
- package/doc/en/ARCHITECTURE_GUIDE.md +79 -0
- package/doc/en/CHANGELOG.md +203 -0
- package/doc/en/CHEATSHEET.md +90 -0
- package/doc/en/CLI.md +111 -0
- package/doc/en/CONTRIBUTING.md +119 -0
- package/doc/en/DEPLOYMENT.md +171 -0
- package/doc/en/FAQ.md +69 -0
- package/doc/en/FEATURES.md +99 -0
- package/doc/en/GETTING_STARTED.md +84 -0
- package/doc/en/INTRODUCTION.md +62 -0
- package/doc/en/PACKAGES.md +63 -0
- package/doc/en/PERFORMANCE.md +98 -0
- package/doc/en/ROADMAP.md +104 -0
- package/doc/en/SECURITY.md +95 -0
- package/doc/en/STRUCTURE.md +79 -0
- package/doc/en/TUTORIAL.md +145 -0
- package/doc/id/ARCHITECTURE_GUIDE.md +76 -0
- package/doc/id/CHANGELOG.md +203 -0
- package/doc/id/CHEATSHEET.md +90 -0
- package/doc/id/CLI.md +139 -0
- package/doc/id/CONTRIBUTING.md +119 -0
- package/doc/id/DEPLOYMENT.md +171 -0
- package/doc/id/FAQ.md +69 -0
- package/doc/id/FEATURES.md +169 -0
- package/doc/id/GETTING_STARTED.md +91 -0
- package/doc/id/INTRODUCTION.md +62 -0
- package/doc/id/PACKAGES.md +63 -0
- package/doc/id/PERFORMANCE.md +100 -0
- package/doc/id/ROADMAP.md +107 -0
- package/doc/id/SECURITY.md +94 -0
- package/doc/id/STRUCTURE.md +79 -0
- package/doc/id/TUTORIAL.md +145 -0
- package/docker-compose.yml +24 -0
- package/ecosystem.config.js +17 -0
- package/eslint.config.mjs +26 -0
- package/gitignore.template +30 -0
- package/lib/bootstrap.ts +210 -0
- package/lib/core/realtime.ts +34 -0
- package/lib/core/redis.ts +139 -0
- package/lib/core/serializer.ts +63 -0
- package/lib/core/server.ts +70 -0
- package/lib/core/store.ts +116 -0
- package/lib/middleware/auth.ts +63 -0
- package/lib/middleware/error.ts +50 -0
- package/lib/middleware/multipart.ts +13 -0
- package/lib/middleware/rateLimit.ts +14 -0
- package/lib/middleware/requestLogger.ts +27 -0
- package/lib/middleware/visitor.ts +178 -0
- package/lib/utils/logger.ts +100 -0
- package/lib/utils/pagination.ts +56 -0
- package/lib/utils/response.ts +88 -0
- package/lib/utils/validator.ts +394 -0
- package/nodemon.json +6 -0
- package/package.json +126 -0
- package/readme.md +357 -0
- package/scripts/check-update.js +92 -0
- package/scripts/config-clear.js +45 -0
- package/scripts/generate-jwt-secret.js +38 -0
- package/scripts/init-project.js +84 -0
- package/scripts/make-module.js +89 -0
- package/scripts/release.js +494 -0
- package/scripts/seed-json.js +158 -0
- package/scripts/verify-rbac-functional.js +187 -0
- package/src/config/app.ts +9 -0
- package/src/config/cors.ts +5 -0
- package/src/modules/Auth/auth.controller.ts +519 -0
- package/src/modules/Rbac/rbac.controller.ts +533 -0
- package/src/routes/auth.ts +74 -0
- package/src/routes/index.ts +7 -0
- package/src/routes/rbac.ts +42 -0
- package/storage/logs/.gitkeep +0 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import { ZodError } from "zod";
|
|
3
|
+
import { sendError } from "../utils/response";
|
|
4
|
+
import { Log } from "../utils/logger";
|
|
5
|
+
|
|
6
|
+
export function errorHandler(
|
|
7
|
+
err: any,
|
|
8
|
+
req: Request,
|
|
9
|
+
res: Response,
|
|
10
|
+
_next: NextFunction
|
|
11
|
+
) {
|
|
12
|
+
// 1. Zod Validation Error
|
|
13
|
+
if (err instanceof ZodError) {
|
|
14
|
+
const formattedErrors = err.errors.map((e) => ({
|
|
15
|
+
field: e.path.join("."),
|
|
16
|
+
message: e.message,
|
|
17
|
+
}));
|
|
18
|
+
return sendError(res, 400, "Validation Error", formattedErrors);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 2. JWT Errors
|
|
22
|
+
if (err.name === "JsonWebTokenError") {
|
|
23
|
+
return sendError(res, 401, "Invalid token");
|
|
24
|
+
}
|
|
25
|
+
if (err.name === "TokenExpiredError") {
|
|
26
|
+
return sendError(res, 401, "Token expired");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 4. Syntax Error (JSON body parsing)
|
|
30
|
+
if (err instanceof SyntaxError && "body" in err) {
|
|
31
|
+
return sendError(res, 400, "Invalid JSON format");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 5. Default / Custom Error
|
|
35
|
+
const code = err.statusCode || 500;
|
|
36
|
+
const msg = err.message || "Internal Server Error";
|
|
37
|
+
|
|
38
|
+
// Log error (file log for production, console for dev)
|
|
39
|
+
if (code === 500) {
|
|
40
|
+
Log.error(msg, {
|
|
41
|
+
error: err,
|
|
42
|
+
path: req.path,
|
|
43
|
+
method: req.method,
|
|
44
|
+
ip: req.ip,
|
|
45
|
+
stack: err.stack,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return sendError(res, code, msg);
|
|
50
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import multer from "multer";
|
|
2
|
+
|
|
3
|
+
// Middleware for parsing multipart/form-data (text fields only)
|
|
4
|
+
export const parseMultipart = multer().none();
|
|
5
|
+
|
|
6
|
+
// Middleware for parsing multipart/form-data with files
|
|
7
|
+
// You can configure storage/limits here as needed
|
|
8
|
+
export const upload = multer({
|
|
9
|
+
dest: "storage/uploads/",
|
|
10
|
+
limits: {
|
|
11
|
+
fileSize: 5 * 1024 * 1024, // 5MB
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import rateLimit from "express-rate-limit";
|
|
2
|
+
// import { redis } from "../core/redis"; // Optional: Use Redis for distributed rate limiting
|
|
3
|
+
|
|
4
|
+
// Rate limiting untuk mencegah brute force dan DDoS ringan
|
|
5
|
+
export const apiLimiter = rateLimit({
|
|
6
|
+
windowMs: 15 * 60 * 1000, // 15 menit
|
|
7
|
+
max: 100, // Batas 100 request per window per IP
|
|
8
|
+
standardHeaders: true, // Return rate limit info di `RateLimit-*` headers
|
|
9
|
+
legacyHeaders: false, // Disable `X-RateLimit-*` headers
|
|
10
|
+
message: {
|
|
11
|
+
success: false,
|
|
12
|
+
message: "Terlalu banyak permintaan, silakan coba lagi nanti.",
|
|
13
|
+
},
|
|
14
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import { Log } from "../utils/logger";
|
|
3
|
+
|
|
4
|
+
export const requestLogger = (
|
|
5
|
+
req: Request,
|
|
6
|
+
res: Response,
|
|
7
|
+
next: NextFunction
|
|
8
|
+
) => {
|
|
9
|
+
const start = Date.now();
|
|
10
|
+
const { method, url, ip } = req;
|
|
11
|
+
|
|
12
|
+
// Log saat response selesai
|
|
13
|
+
res.on("finish", () => {
|
|
14
|
+
const duration = Date.now() - start;
|
|
15
|
+
const { statusCode } = res;
|
|
16
|
+
|
|
17
|
+
const message = `${method} ${url} ${statusCode} - ${duration}ms - ${ip}`;
|
|
18
|
+
|
|
19
|
+
if (statusCode >= 400) {
|
|
20
|
+
Log.warn(message);
|
|
21
|
+
} else {
|
|
22
|
+
Log.info(message);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
next();
|
|
27
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import { v4 as uuidv4 } from "uuid";
|
|
3
|
+
import { redis } from "../core/redis";
|
|
4
|
+
|
|
5
|
+
type DayMemoryStats = {
|
|
6
|
+
requests: number;
|
|
7
|
+
newVisitors: number;
|
|
8
|
+
visitors: Set<string>;
|
|
9
|
+
newVisitorsMobile: number;
|
|
10
|
+
visitorsMobile: Set<string>;
|
|
11
|
+
ipAddresses: number;
|
|
12
|
+
ipSet: Set<string>;
|
|
13
|
+
sessions: number;
|
|
14
|
+
sessionSet: Set<string>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const memoryStats = new Map<string, DayMemoryStats>();
|
|
18
|
+
const globalVisitors = new Set<string>();
|
|
19
|
+
|
|
20
|
+
function formatDateKey(d: Date) {
|
|
21
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
22
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
23
|
+
const yyyy = d.getFullYear();
|
|
24
|
+
return `${dd}-${mm}-${yyyy}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseCookies(header: string | undefined) {
|
|
28
|
+
const cookies: Record<string, string> = {};
|
|
29
|
+
if (!header) return cookies;
|
|
30
|
+
const parts = header.split(";");
|
|
31
|
+
for (const part of parts) {
|
|
32
|
+
const [k, v] = part.split("=").map((s) => s.trim());
|
|
33
|
+
if (k && v) cookies[k] = decodeURIComponent(v);
|
|
34
|
+
}
|
|
35
|
+
return cookies;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isMobileUserAgent(ua: string | undefined) {
|
|
39
|
+
if (!ua) return false;
|
|
40
|
+
return /Mobile|Android|iPhone|iPad|iPod/i.test(ua);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function visitorCounter(
|
|
44
|
+
req: Request,
|
|
45
|
+
res: Response,
|
|
46
|
+
next: NextFunction
|
|
47
|
+
) {
|
|
48
|
+
const now = new Date();
|
|
49
|
+
const dateKey = formatDateKey(now);
|
|
50
|
+
const ip =
|
|
51
|
+
req.ip ||
|
|
52
|
+
(req.headers["x-forwarded-for"] as string | undefined) ||
|
|
53
|
+
req.socket.remoteAddress ||
|
|
54
|
+
"";
|
|
55
|
+
const userAgent = req.headers["user-agent"] as string | undefined;
|
|
56
|
+
const mobile = isMobileUserAgent(userAgent);
|
|
57
|
+
|
|
58
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
59
|
+
let visitorId = cookies["visitor_id"];
|
|
60
|
+
if (!visitorId) {
|
|
61
|
+
visitorId = uuidv4();
|
|
62
|
+
res.cookie("visitor_id", visitorId, {
|
|
63
|
+
httpOnly: true,
|
|
64
|
+
sameSite: "lax",
|
|
65
|
+
maxAge: 365 * 24 * 60 * 60 * 1000,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let sessionId = cookies["visitor_session_id"];
|
|
70
|
+
if (!sessionId) {
|
|
71
|
+
sessionId = uuidv4();
|
|
72
|
+
res.cookie("visitor_session_id", sessionId, {
|
|
73
|
+
httpOnly: true,
|
|
74
|
+
sameSite: "lax",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (redis && redis.status === "ready") {
|
|
79
|
+
const base = dateKey;
|
|
80
|
+
const kRequests = `requests-${base}`;
|
|
81
|
+
const kNewVisitors = `new-visitors-${base}`;
|
|
82
|
+
const kVisitors = `visitors-${base}`;
|
|
83
|
+
const kNewVisitorsMobile = `new-visitors-from-mobile-${base}`;
|
|
84
|
+
const kVisitorsMobile = `visitors-from-mobile-${base}`;
|
|
85
|
+
const kIpAddresses = `ip-addresses-${base}`;
|
|
86
|
+
const kSessions = `sessions-${base}`;
|
|
87
|
+
const kVisitorsSet = `visitors-set-${base}`;
|
|
88
|
+
const kVisitorsMobileSet = `visitors-from-mobile-set-${base}`;
|
|
89
|
+
const kIpSet = `ip-addresses-set-${base}`;
|
|
90
|
+
const kSessionsSet = `sessions-set-${base}`;
|
|
91
|
+
const kVisitorsAll = `visitors-all`;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await redis.incr(kRequests);
|
|
95
|
+
|
|
96
|
+
const isNewEver = await redis.sadd(kVisitorsAll, visitorId);
|
|
97
|
+
if (isNewEver === 1) {
|
|
98
|
+
await redis.incr(kNewVisitors);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const addedVisitor = await redis.sadd(kVisitorsSet, visitorId);
|
|
102
|
+
if (addedVisitor === 1) {
|
|
103
|
+
await redis.incr(kVisitors);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (mobile) {
|
|
107
|
+
const addedMobileVisitor = await redis.sadd(
|
|
108
|
+
kVisitorsMobileSet,
|
|
109
|
+
visitorId
|
|
110
|
+
);
|
|
111
|
+
if (addedMobileVisitor === 1) {
|
|
112
|
+
await redis.incr(kVisitorsMobile);
|
|
113
|
+
}
|
|
114
|
+
if (isNewEver === 1) {
|
|
115
|
+
await redis.incr(kNewVisitorsMobile);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (ip) {
|
|
120
|
+
const addedIp = await redis.sadd(kIpSet, ip);
|
|
121
|
+
if (addedIp === 1) {
|
|
122
|
+
await redis.incr(kIpAddresses);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const addedSession = await redis.sadd(kSessionsSet, sessionId);
|
|
127
|
+
if (addedSession === 1) {
|
|
128
|
+
await redis.incr(kSessions);
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
} else {
|
|
132
|
+
let stats = memoryStats.get(dateKey);
|
|
133
|
+
if (!stats) {
|
|
134
|
+
stats = {
|
|
135
|
+
requests: 0,
|
|
136
|
+
newVisitors: 0,
|
|
137
|
+
visitors: new Set<string>(),
|
|
138
|
+
newVisitorsMobile: 0,
|
|
139
|
+
visitorsMobile: new Set<string>(),
|
|
140
|
+
ipAddresses: 0,
|
|
141
|
+
ipSet: new Set<string>(),
|
|
142
|
+
sessions: 0,
|
|
143
|
+
sessionSet: new Set<string>(),
|
|
144
|
+
};
|
|
145
|
+
memoryStats.set(dateKey, stats);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
stats.requests += 1;
|
|
149
|
+
|
|
150
|
+
if (!globalVisitors.has(visitorId)) {
|
|
151
|
+
globalVisitors.add(visitorId);
|
|
152
|
+
stats.newVisitors += 1;
|
|
153
|
+
if (mobile) {
|
|
154
|
+
stats.newVisitorsMobile += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!stats.visitors.has(visitorId)) {
|
|
159
|
+
stats.visitors.add(visitorId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (mobile && !stats.visitorsMobile.has(visitorId)) {
|
|
163
|
+
stats.visitorsMobile.add(visitorId);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (ip && !stats.ipSet.has(ip)) {
|
|
167
|
+
stats.ipSet.add(ip);
|
|
168
|
+
stats.ipAddresses += 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!stats.sessionSet.has(sessionId)) {
|
|
172
|
+
stats.sessionSet.add(sessionId);
|
|
173
|
+
stats.sessions += 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
next();
|
|
178
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import winston from "winston";
|
|
2
|
+
import "winston-daily-rotate-file";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
|
|
6
|
+
const logDirectory = path.join(process.cwd(), "storage", "logs");
|
|
7
|
+
|
|
8
|
+
// Ensure log directory exists
|
|
9
|
+
if (!fs.existsSync(logDirectory)) {
|
|
10
|
+
fs.mkdirSync(logDirectory, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const dailyRotateFileTransport = new winston.transports.DailyRotateFile({
|
|
14
|
+
filename: "lapeeh-%DATE%.log",
|
|
15
|
+
dirname: logDirectory,
|
|
16
|
+
datePattern: "YYYY-MM-DD",
|
|
17
|
+
zippedArchive: true,
|
|
18
|
+
maxSize: "20m",
|
|
19
|
+
maxFiles: "3d",
|
|
20
|
+
format: winston.format.combine(
|
|
21
|
+
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
|
|
22
|
+
winston.format.printf((info: any) => {
|
|
23
|
+
let log = `[${info.timestamp}] ${info.level.toUpperCase()}: ${
|
|
24
|
+
info.message
|
|
25
|
+
}`;
|
|
26
|
+
|
|
27
|
+
// Handle metadata (errors, etc)
|
|
28
|
+
const { timestamp, level, message, service, ...meta } = info;
|
|
29
|
+
if (Object.keys(meta).length > 0) {
|
|
30
|
+
// If meta has 'errors', nicely format it
|
|
31
|
+
if (meta.errors) {
|
|
32
|
+
log += `\nErrors: ${JSON.stringify(meta.errors, null, 2)}`;
|
|
33
|
+
delete meta.errors;
|
|
34
|
+
}
|
|
35
|
+
// If there are other meta properties remaining, log them
|
|
36
|
+
if (Object.keys(meta).length > 0 && !meta.stack && !meta.error) {
|
|
37
|
+
log += `\nMeta: ${JSON.stringify(meta)}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (info.stack) {
|
|
42
|
+
log += `\n${info.stack}`;
|
|
43
|
+
} else if (info.error && info.error.stack) {
|
|
44
|
+
log += `\n${info.error.stack}`;
|
|
45
|
+
}
|
|
46
|
+
return log;
|
|
47
|
+
})
|
|
48
|
+
),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const logger = winston.createLogger({
|
|
52
|
+
level: process.env.LOG_LEVEL || "info",
|
|
53
|
+
format: winston.format.combine(
|
|
54
|
+
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
|
|
55
|
+
winston.format.errors({ stack: true }),
|
|
56
|
+
winston.format.splat(),
|
|
57
|
+
winston.format.json()
|
|
58
|
+
),
|
|
59
|
+
defaultMeta: { service: "lapeeh-service" },
|
|
60
|
+
transports: [
|
|
61
|
+
// Write all logs with importance level of `error` or less to `error.log`
|
|
62
|
+
// new winston.transports.File({ filename: 'error.log', level: 'error' }),
|
|
63
|
+
// Write all logs to `combined.log`
|
|
64
|
+
// new winston.transports.File({ filename: 'combined.log' }),
|
|
65
|
+
dailyRotateFileTransport,
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// If we're not in production then log to the `console` with the format:
|
|
70
|
+
// `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
|
|
71
|
+
if (process.env.NODE_ENV !== "production") {
|
|
72
|
+
logger.add(
|
|
73
|
+
new winston.transports.Console({
|
|
74
|
+
format: winston.format.combine(
|
|
75
|
+
winston.format.colorize(),
|
|
76
|
+
winston.format.simple()
|
|
77
|
+
),
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class Log {
|
|
83
|
+
static info(message: string, meta?: any) {
|
|
84
|
+
logger.info(message, meta);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static error(message: string, meta?: any) {
|
|
88
|
+
logger.error(message, meta);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static warn(message: string, meta?: any) {
|
|
92
|
+
logger.warn(message, meta);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static debug(message: string, meta?: any) {
|
|
96
|
+
logger.debug(message, meta);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default logger;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type PaginationQuery = {
|
|
2
|
+
page?: string | string[] | number;
|
|
3
|
+
per_page?: string | string[] | number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type PaginationParams = {
|
|
7
|
+
page: number;
|
|
8
|
+
perPage: number;
|
|
9
|
+
skip: number;
|
|
10
|
+
take: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type PaginationMeta = {
|
|
14
|
+
page: number;
|
|
15
|
+
perPage: number;
|
|
16
|
+
total: number;
|
|
17
|
+
lastPage: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function toNumber(value: string | string[] | number | undefined) {
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
if (value.length === 0) return undefined;
|
|
23
|
+
return toNumber(value[0]);
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === "number") {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
if (typeof value === "string") {
|
|
29
|
+
const n = parseInt(value, 10);
|
|
30
|
+
if (!Number.isNaN(n)) {
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getPagination(query: PaginationQuery): PaginationParams {
|
|
38
|
+
const pageRaw = toNumber(query.page);
|
|
39
|
+
const perPageRaw = toNumber(query.per_page);
|
|
40
|
+
const page = pageRaw && pageRaw > 0 ? pageRaw : 1;
|
|
41
|
+
const perPage =
|
|
42
|
+
perPageRaw && perPageRaw > 0 && perPageRaw <= 100 ? perPageRaw : 10;
|
|
43
|
+
const skip = (page - 1) * perPage;
|
|
44
|
+
const take = perPage;
|
|
45
|
+
return { page, perPage, skip, take };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildPaginationMeta(
|
|
49
|
+
page: number,
|
|
50
|
+
perPage: number,
|
|
51
|
+
total: number
|
|
52
|
+
): PaginationMeta {
|
|
53
|
+
const lastPage = total === 0 ? 1 : Math.ceil(total / perPage);
|
|
54
|
+
return { page, perPage, total, lastPage };
|
|
55
|
+
}
|
|
56
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Response } from "express";
|
|
2
|
+
import { Log } from "./logger";
|
|
3
|
+
|
|
4
|
+
type SuccessStatus = "success";
|
|
5
|
+
type ErrorStatus = "error";
|
|
6
|
+
|
|
7
|
+
type SuccessBody<T> = {
|
|
8
|
+
status: SuccessStatus;
|
|
9
|
+
message: string;
|
|
10
|
+
data?: T;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ErrorBody<T = unknown> = {
|
|
14
|
+
status: ErrorStatus;
|
|
15
|
+
message: string;
|
|
16
|
+
errors?: T;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function toJsonSafe(value: unknown): unknown {
|
|
20
|
+
if (value instanceof Date) {
|
|
21
|
+
return value.toISOString();
|
|
22
|
+
}
|
|
23
|
+
if (typeof value === "bigint") {
|
|
24
|
+
return value.toString();
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
return value.map((item) => toJsonSafe(item));
|
|
28
|
+
}
|
|
29
|
+
if (value && typeof value === "object") {
|
|
30
|
+
const result: Record<string, unknown> = {};
|
|
31
|
+
for (const [key, val] of Object.entries(value)) {
|
|
32
|
+
result[key] = toJsonSafe(val);
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function sendSuccess<T = any>(
|
|
40
|
+
res: Response,
|
|
41
|
+
statusCode: number,
|
|
42
|
+
message: string,
|
|
43
|
+
data?: T
|
|
44
|
+
) {
|
|
45
|
+
const body: SuccessBody<T | undefined> = { status: "success", message, data };
|
|
46
|
+
return res.status(statusCode).json(toJsonSafe(body));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mengirim response sukses dengan performa tinggi menggunakan Schema Serialization (Fastify-style).
|
|
51
|
+
* Melewati proses JSON.stringify standar yang lambat.
|
|
52
|
+
*
|
|
53
|
+
* @param serializer Fungsi serializer yang sudah dicompile dari src/core/serializer
|
|
54
|
+
*/
|
|
55
|
+
export function sendFastSuccess(
|
|
56
|
+
res: Response,
|
|
57
|
+
statusCode: number,
|
|
58
|
+
serializer: (doc: any) => string,
|
|
59
|
+
data: any
|
|
60
|
+
) {
|
|
61
|
+
// Set header manual karena kita mengirim raw string
|
|
62
|
+
res.setHeader("Content-Type", "application/json");
|
|
63
|
+
res.status(statusCode);
|
|
64
|
+
|
|
65
|
+
// Serializer mengembalikan string JSON
|
|
66
|
+
const jsonString = serializer(data);
|
|
67
|
+
return res.send(jsonString);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function sendError<T = unknown>(
|
|
71
|
+
res: Response,
|
|
72
|
+
statusCode: number,
|
|
73
|
+
message: string,
|
|
74
|
+
errors?: T
|
|
75
|
+
) {
|
|
76
|
+
// Log the error
|
|
77
|
+
if (statusCode >= 500) {
|
|
78
|
+
Log.error(message, { statusCode, errors });
|
|
79
|
+
} else if (statusCode >= 400) {
|
|
80
|
+
Log.warn(message, { statusCode, errors });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const body: ErrorBody<T> = { status: "error", message };
|
|
84
|
+
if (errors !== undefined) {
|
|
85
|
+
body.errors = errors;
|
|
86
|
+
}
|
|
87
|
+
return res.status(statusCode).json(toJsonSafe(body));
|
|
88
|
+
}
|