thatcher 1.0.4
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/LICENSE +21 -0
- package/README.md +398 -0
- package/package.json +73 -0
- package/src/adapters/google-auth.js +148 -0
- package/src/adapters/google-drive.js +209 -0
- package/src/app/api/[entity]/[[...path]]/route.js +33 -0
- package/src/app/api/audit/dashboard/route.js +77 -0
- package/src/app/api/audit/logs/route.js +46 -0
- package/src/app/api/audit/permissions/[id]/route.js +37 -0
- package/src/app/api/audit/permissions/route.js +93 -0
- package/src/app/api/audit/permissions/stats/route.js +29 -0
- package/src/app/api/audit/route.js +79 -0
- package/src/app/api/audit/stats/route.js +18 -0
- package/src/app/api/auth/google/callback/route.js +94 -0
- package/src/app/api/auth/google/route.js +58 -0
- package/src/app/api/auth/login/route.js +121 -0
- package/src/app/api/auth/logout/route.js +52 -0
- package/src/app/api/auth/me/route.js +16 -0
- package/src/app/api/auth/mwr-bridge/route.js +77 -0
- package/src/app/api/auth/password-reset/route.js +65 -0
- package/src/app/api/cron/trigger/route.js +58 -0
- package/src/app/api/csrf-token/route.js +8 -0
- package/src/app/api/debug/config/route.js +22 -0
- package/src/app/api/debug/hooks/route.js +16 -0
- package/src/app/api/debug/plugins/route.js +20 -0
- package/src/app/api/debug/sqlite/route.js +21 -0
- package/src/app/api/debug/sync/route.js +29 -0
- package/src/app/api/debug/workflow/route.js +20 -0
- package/src/app/api/domains/[domain]/route.js +26 -0
- package/src/app/api/domains/route.js +21 -0
- package/src/app/api/email/allocate/batch/route.js +106 -0
- package/src/app/api/email/allocate/route.js +141 -0
- package/src/app/api/email/receive/route.js +158 -0
- package/src/app/api/email/route.js +3 -0
- package/src/app/api/email/send/route.js +77 -0
- package/src/app/api/email/unallocated/route.js +47 -0
- package/src/app/api/files/[id]/route.js +38 -0
- package/src/app/api/health/route.js +95 -0
- package/src/app/api/metrics/route.js +73 -0
- package/src/app/api/monitoring/dashboard/route.js +18 -0
- package/src/cli.js +243 -0
- package/src/config/config-loader.js +112 -0
- package/src/config/constants.js +127 -0
- package/src/config/env.js +164 -0
- package/src/config/spec-helpers.js +232 -0
- package/src/engine.server.js +212 -0
- package/src/index.js +368 -0
- package/src/lib/accessibility.js +162 -0
- package/src/lib/action-factory.js +34 -0
- package/src/lib/action-utils.js +21 -0
- package/src/lib/alert-manager.js +189 -0
- package/src/lib/api-error-wrapper.js +125 -0
- package/src/lib/api-helpers.js +53 -0
- package/src/lib/api.js +82 -0
- package/src/lib/audit-logger-enhanced.js +117 -0
- package/src/lib/audit-logger.js +193 -0
- package/src/lib/auth-middleware.js +102 -0
- package/src/lib/auth-route-helpers.js +83 -0
- package/src/lib/business-rules-engine.js +86 -0
- package/src/lib/compression.js +44 -0
- package/src/lib/config-field-helpers.js +91 -0
- package/src/lib/config-generator-engine.js +445 -0
- package/src/lib/config-helpers.js +120 -0
- package/src/lib/connection-guard.js +79 -0
- package/src/lib/crud-action-helpers.js +34 -0
- package/src/lib/crud-factory.js +83 -0
- package/src/lib/crud-handlers.js +244 -0
- package/src/lib/csrf-protection.js +63 -0
- package/src/lib/database-core.js +258 -0
- package/src/lib/database-migrations.js +96 -0
- package/src/lib/date-utils.js +159 -0
- package/src/lib/db-backup.js +97 -0
- package/src/lib/db-monitor.js +127 -0
- package/src/lib/domain-loader.js +82 -0
- package/src/lib/email-sender.js +100 -0
- package/src/lib/error-boundary.js +134 -0
- package/src/lib/error-handler.js +84 -0
- package/src/lib/error-recovery.js +190 -0
- package/src/lib/error-resilience.js +130 -0
- package/src/lib/errors.js +69 -0
- package/src/lib/events-engine.js +182 -0
- package/src/lib/field-iterator.js +50 -0
- package/src/lib/field-registry.js +68 -0
- package/src/lib/field-types.js +154 -0
- package/src/lib/generic-crud-handler.js +32 -0
- package/src/lib/health-monitor.js +134 -0
- package/src/lib/hook-engine.js +169 -0
- package/src/lib/hot-reload/cache-invalidator.js +115 -0
- package/src/lib/hot-reload/checkpoint.js +95 -0
- package/src/lib/hot-reload/debug-exposure.js +67 -0
- package/src/lib/hot-reload/directory-watcher.js +96 -0
- package/src/lib/hot-reload/index.js +50 -0
- package/src/lib/hot-reload/mutex.js +75 -0
- package/src/lib/hot-reload/promise-container.js +66 -0
- package/src/lib/hot-reload/route-wrapper.js +46 -0
- package/src/lib/hot-reload/safe-error.js +51 -0
- package/src/lib/hot-reload/supervisor.js +161 -0
- package/src/lib/hot-reload/timeout-wrapper.js +52 -0
- package/src/lib/http-methods-factory.js +25 -0
- package/src/lib/index-optimizer.js +96 -0
- package/src/lib/index.js +35 -0
- package/src/lib/list-data-transform.js +39 -0
- package/src/lib/log-aggregator.js +116 -0
- package/src/lib/logger.js +55 -0
- package/src/lib/metrics-collector.js +102 -0
- package/src/lib/minifier.js +19 -0
- package/src/lib/monitoring-init.js +67 -0
- package/src/lib/next-compat.js +80 -0
- package/src/lib/next-polyfills.js +135 -0
- package/src/lib/perf-monitor.js +91 -0
- package/src/lib/progress-components.js +181 -0
- package/src/lib/query-cache.js +126 -0
- package/src/lib/query-engine-write.js +221 -0
- package/src/lib/query-engine.js +399 -0
- package/src/lib/query-perf.js +117 -0
- package/src/lib/query-string-adapter.js +75 -0
- package/src/lib/realtime-server.js +67 -0
- package/src/lib/render-cache.js +61 -0
- package/src/lib/request-tracker.js +43 -0
- package/src/lib/resource-hints.js +29 -0
- package/src/lib/resource-monitor.js +117 -0
- package/src/lib/response-formatter.js +80 -0
- package/src/lib/route-helpers.js +33 -0
- package/src/lib/route-resolver.js +142 -0
- package/src/lib/safe-json.js +8 -0
- package/src/lib/server-bootstrap.js +71 -0
- package/src/lib/stage-pipeline.js +153 -0
- package/src/lib/state-protocol.js +171 -0
- package/src/lib/state-transport-client.js +169 -0
- package/src/lib/state-transport-reconnect.js +121 -0
- package/src/lib/state-transport-server.js +181 -0
- package/src/lib/static-server.js +97 -0
- package/src/lib/status-helpers.js +98 -0
- package/src/lib/universal-handler.js +7 -0
- package/src/lib/utils.js +93 -0
- package/src/lib/validate.js +197 -0
- package/src/lib/validation/business-validators.js +61 -0
- package/src/lib/validation/csrf.js +51 -0
- package/src/lib/validation/file-validators.js +34 -0
- package/src/lib/validation/format-validators.js +106 -0
- package/src/lib/validation/index.js +19 -0
- package/src/lib/validation/rate-limit.js +31 -0
- package/src/lib/validation/security-validators.js +78 -0
- package/src/lib/validation-middleware.js +133 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/with-audit-logging.js +63 -0
- package/src/lib/with-error-handler.js +31 -0
- package/src/lib/workflow-engine.js +250 -0
- package/src/server/server.js +305 -0
- package/src/services/collaborator-role.service.js +205 -0
- package/src/services/email-sender.js +105 -0
- package/src/services/notification-engine.js +110 -0
- package/src/services/permission.service.js +181 -0
- package/src/ui/advanced-search-renderer.js +42 -0
- package/src/ui/advanced-widgets.js +47 -0
- package/src/ui/auth-pages.js +114 -0
- package/src/ui/auth-styles.js +53 -0
- package/src/ui/client.js +99 -0
- package/src/ui/collaboration-dialogs.js +31 -0
- package/src/ui/common-handlers.js +156 -0
- package/src/ui/component-engine.js +103 -0
- package/src/ui/dashboard-renderer.js +150 -0
- package/src/ui/dialog-engine.js +147 -0
- package/src/ui/dialog-factory.js +38 -0
- package/src/ui/engagement-cards.js +76 -0
- package/src/ui/engagement-dialogs.js +100 -0
- package/src/ui/engagement-grid-renderer.js +109 -0
- package/src/ui/entity-renderer.js +176 -0
- package/src/ui/event-delegation.js +108 -0
- package/src/ui/fetch-json.js +24 -0
- package/src/ui/file-dialogs.js +81 -0
- package/src/ui/flexup-report-renderer.js +173 -0
- package/src/ui/format-helpers.js +102 -0
- package/src/ui/global-tags.js +144 -0
- package/src/ui/highlight-threading-renderer.js +176 -0
- package/src/ui/idle-logout.js +156 -0
- package/src/ui/job-management-renderer.js +61 -0
- package/src/ui/layout.js +219 -0
- package/src/ui/letter-dialogs.js +37 -0
- package/src/ui/ml-console-renderer.js +108 -0
- package/src/ui/monitoring-dashboard-client.js +136 -0
- package/src/ui/monitoring-dashboard.js +134 -0
- package/src/ui/notifications-renderer.js +51 -0
- package/src/ui/page-handler-admin.js +121 -0
- package/src/ui/page-handler-helpers.js +111 -0
- package/src/ui/page-handler-reviews.js +165 -0
- package/src/ui/page-handler-rfi.js +42 -0
- package/src/ui/page-handler.js +210 -0
- package/src/ui/password-reset-page.js +146 -0
- package/src/ui/perf-helpers.js +96 -0
- package/src/ui/perf-renderer.js +71 -0
- package/src/ui/permissions-ui.js +163 -0
- package/src/ui/picker-dialogs.js +100 -0
- package/src/ui/render-helpers.js +124 -0
- package/src/ui/renderer.js +35 -0
- package/src/ui/review-comparison-renderer.js +58 -0
- package/src/ui/review-detail-panels.js +71 -0
- package/src/ui/review-detail-renderer.js +170 -0
- package/src/ui/review-detail-script.js +95 -0
- package/src/ui/review-mwr-renderer.js +113 -0
- package/src/ui/review-renderer.js +202 -0
- package/src/ui/review-widgets.js +88 -0
- package/src/ui/review-zone-nav.js +12 -0
- package/src/ui/rfi-detail-renderer.js +191 -0
- package/src/ui/rfi-renderer.js +194 -0
- package/src/ui/rfi-report-renderer.js +56 -0
- package/src/ui/rippleui.css +1 -0
- package/src/ui/settings-renderer-advanced.js +195 -0
- package/src/ui/settings-renderer-advanced2.js +158 -0
- package/src/ui/settings-renderer-teams.js +112 -0
- package/src/ui/settings-renderer.js +166 -0
- package/src/ui/spacing-system.js +155 -0
- package/src/ui/standalone-login.js +109 -0
- package/src/ui/styles.css +2530 -0
- package/src/ui/styles2.css +1602 -0
- package/src/ui/test-page.js +23 -0
- package/src/ui/validation-rules.js +73 -0
- package/src/ui/validation-ui.js +147 -0
- package/src/ui/virtual-scroll.js +107 -0
- package/src/ui/webjsx.js +61 -0
- package/src/ui/widgets.js +152 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Drive Adapter - File storage and manipulation
|
|
3
|
+
* Upload, download, copy, export to PDF
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { google } from 'googleapis';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { getJWTClient, getOAuth2Client } from './google-auth.js';
|
|
10
|
+
import { buildConfig } from '../config/env.js';
|
|
11
|
+
|
|
12
|
+
const DRIVE_SCOPES = ['https://www.googleapis.com/auth/drive'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get authenticated Drive API client
|
|
16
|
+
* @param {object} [user] - User with oauth_token, or null for service account
|
|
17
|
+
* @returns {object}
|
|
18
|
+
*/
|
|
19
|
+
export function getDriveClient(user = null) {
|
|
20
|
+
let client;
|
|
21
|
+
|
|
22
|
+
if (user?.oauth_token) {
|
|
23
|
+
// User-delegated access
|
|
24
|
+
client = getOAuth2Client();
|
|
25
|
+
client.setCredentials({ access_token: user.oauth_token });
|
|
26
|
+
} else {
|
|
27
|
+
// Service account (app-wide)
|
|
28
|
+
client = getJWTClient();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!client) throw new Error('Google Drive not configured');
|
|
32
|
+
|
|
33
|
+
return google.drive({ version: 'v3', auth: client });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Upload a file to Google Drive
|
|
38
|
+
* @param {string} filePath - Local file path
|
|
39
|
+
* @param {string} name - File name in Drive
|
|
40
|
+
* @param {object} [options] - { folderId, mimeType, parents }
|
|
41
|
+
* @param {object} [user] - Optional user context
|
|
42
|
+
* @returns {object} File metadata
|
|
43
|
+
*/
|
|
44
|
+
export async function uploadFile(filePath, name, options = {}, user = null) {
|
|
45
|
+
const drive = getDriveClient(user);
|
|
46
|
+
const absPath = path.resolve(filePath);
|
|
47
|
+
const fileSize = fs.statSync(absPath).size;
|
|
48
|
+
|
|
49
|
+
const res = await drive.files.create({
|
|
50
|
+
requestBody: {
|
|
51
|
+
name,
|
|
52
|
+
parents: options.parents || [options.folderId || getDefaultFolder()],
|
|
53
|
+
mimeType: options.mimeType || 'application/octet-stream',
|
|
54
|
+
},
|
|
55
|
+
media: {
|
|
56
|
+
mimeType: options.mimeType || 'application/octet-stream',
|
|
57
|
+
body: fs.createReadStream(absPath),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return res.data;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Download file from Drive
|
|
66
|
+
* @param {string} fileId
|
|
67
|
+
* @param {string} [destPath] - If provided, write to file; else return buffer
|
|
68
|
+
* @param {object} [user] - Optional user context
|
|
69
|
+
* @returns {Buffer|string}
|
|
70
|
+
*/
|
|
71
|
+
export async function downloadFile(fileId, destPath = null, user = null) {
|
|
72
|
+
const drive = getDriveClient(user);
|
|
73
|
+
const res = await drive.files.get(
|
|
74
|
+
{ fileId, alt: 'media' },
|
|
75
|
+
{ responseType: destPath ? 'stream' : 'arraybuffer' }
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (destPath) {
|
|
79
|
+
const writeStream = fs.createWriteStream(destPath);
|
|
80
|
+
res.data
|
|
81
|
+
.on('end', () => {})
|
|
82
|
+
.on('error', err => { throw err; })
|
|
83
|
+
.pipe(writeStream);
|
|
84
|
+
return destPath;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return Buffer.from(res.data);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a copy of a file in Drive
|
|
92
|
+
* @param {string} fileId
|
|
93
|
+
* @param {string} [newName]
|
|
94
|
+
* @param {string} [folderId]
|
|
95
|
+
* @param {object} [user]
|
|
96
|
+
* @returns {object}
|
|
97
|
+
*/
|
|
98
|
+
export async function copyFile(fileId, newName = null, folderId = null, user = null) {
|
|
99
|
+
const drive = getDriveClient(user);
|
|
100
|
+
const res = await drive.files.copy({
|
|
101
|
+
requestBody: {
|
|
102
|
+
name: newName,
|
|
103
|
+
parents: folderId ? [folderId] : undefined,
|
|
104
|
+
},
|
|
105
|
+
fileId,
|
|
106
|
+
});
|
|
107
|
+
return res.data;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Export Google Doc to PDF
|
|
112
|
+
* @param {string} fileId
|
|
113
|
+
* @param {object} [user]
|
|
114
|
+
* @returns {Buffer}
|
|
115
|
+
*/
|
|
116
|
+
export async function exportToPdf(fileId, user = null) {
|
|
117
|
+
const drive = getDriveClient(user);
|
|
118
|
+
const res = await drive.files.export(
|
|
119
|
+
{ fileId, mimeType: 'application/pdf' },
|
|
120
|
+
{ responseType: 'arraybuffer' }
|
|
121
|
+
);
|
|
122
|
+
return Buffer.from(res.data);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* List files in folder
|
|
127
|
+
* @param {string} [folderId]
|
|
128
|
+
* @param {object} [query]
|
|
129
|
+
* @param {object} [user]
|
|
130
|
+
* @returns {Array}
|
|
131
|
+
*/
|
|
132
|
+
export async function listFiles(folderId = null, query = {}, user = null) {
|
|
133
|
+
const drive = getDriveClient(user);
|
|
134
|
+
const qParts = [];
|
|
135
|
+
|
|
136
|
+
if (folderId) {
|
|
137
|
+
qParts.push(`'${folderId}' in parents`);
|
|
138
|
+
}
|
|
139
|
+
if (query.trashed === false) {
|
|
140
|
+
qParts.push("trashed = false");
|
|
141
|
+
}
|
|
142
|
+
if (query.mimeType) {
|
|
143
|
+
qParts.push(`mimeType = '${query.mimeType}'`);
|
|
144
|
+
}
|
|
145
|
+
if (query.name) {
|
|
146
|
+
qParts.push(`name = '${query.name}'`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const res = await drive.files.list({
|
|
150
|
+
q: qParts.length ? qParts.join(' AND ') : undefined,
|
|
151
|
+
fields: 'files(id, name, mimeType, size, modifiedTime)',
|
|
152
|
+
pageSize: 100,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return res.data.files || [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Delete file
|
|
160
|
+
* @param {string} fileId
|
|
161
|
+
* @param {object} [user]
|
|
162
|
+
*/
|
|
163
|
+
export async function deleteFile(fileId, user = null) {
|
|
164
|
+
const drive = getDriveClient(user);
|
|
165
|
+
await drive.files.delete({ fileId });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get default root folder from config
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
function getDefaultFolder() {
|
|
173
|
+
const cfg = buildConfig();
|
|
174
|
+
return cfg.drive.rootFolderId || 'root';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Ensure folder exists, create if not
|
|
179
|
+
* @param {string} name
|
|
180
|
+
* @param {string} [parentId]
|
|
181
|
+
* @param {object} [user]
|
|
182
|
+
* @returns {string} folderId
|
|
183
|
+
*/
|
|
184
|
+
export async function ensureFolder(name, parentId = null, user = null) {
|
|
185
|
+
const drive = getDriveClient(user);
|
|
186
|
+
const queryParts = [`name = '${name}'`, "mimeType = 'application/vnd.google-apps.folder'"];
|
|
187
|
+
if (parentId) queryParts.push(`'${parentId}' in parents`);
|
|
188
|
+
|
|
189
|
+
const res = await drive.files.list({
|
|
190
|
+
q: queryParts.join(' AND '),
|
|
191
|
+
fields: 'files(id)',
|
|
192
|
+
pageSize: 1,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (res.data.files.length > 0) {
|
|
196
|
+
return res.data.files[0].id;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Create folder
|
|
200
|
+
const createRes = await drive.files.create({
|
|
201
|
+
requestBody: {
|
|
202
|
+
name,
|
|
203
|
+
mimeType: 'application/vnd.google-apps.folder',
|
|
204
|
+
parents: parentId ? [parentId] : undefined,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return createRes.data.id;
|
|
209
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createCrudHandlers } from '@/lib/crud-factory';
|
|
2
|
+
import { setCurrentRequest } from '@/engine.server';
|
|
3
|
+
|
|
4
|
+
function wrapHandler(method) {
|
|
5
|
+
return async (request, context) => {
|
|
6
|
+
setCurrentRequest(request);
|
|
7
|
+
const params = await context.params;
|
|
8
|
+
const pathArray = params.path || [];
|
|
9
|
+
const entityName = params.entity;
|
|
10
|
+
const parentEntity = params.parentEntity || null;
|
|
11
|
+
const parentId = params.parentId || null;
|
|
12
|
+
|
|
13
|
+
const enhancedContext = {
|
|
14
|
+
...context,
|
|
15
|
+
params: {
|
|
16
|
+
entity: entityName,
|
|
17
|
+
id: pathArray[0] || null,
|
|
18
|
+
childKey: pathArray[1] || null,
|
|
19
|
+
parentEntity,
|
|
20
|
+
parentId,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const handlers = createCrudHandlers(entityName);
|
|
25
|
+
return handlers(request, enhancedContext);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const GET = wrapHandler('GET');
|
|
30
|
+
export const POST = wrapHandler('POST');
|
|
31
|
+
export const PUT = wrapHandler('PUT');
|
|
32
|
+
export const PATCH = wrapHandler('PATCH');
|
|
33
|
+
export const DELETE = wrapHandler('DELETE');
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { NextResponse } from '@/lib/next-polyfills';
|
|
2
|
+
import { getUser, setCurrentRequest } from '@/engine.server';
|
|
3
|
+
import {
|
|
4
|
+
getPermissionAuditTrail, getPermissionAuditStats, getPermissionAuditBreakdown,
|
|
5
|
+
getPermissionAuditByDateRange, searchPermissionAudit, getPermissionDiff,
|
|
6
|
+
} from '@/lib/audit-logger';
|
|
7
|
+
|
|
8
|
+
export async function GET(request) {
|
|
9
|
+
setCurrentRequest(request);
|
|
10
|
+
try {
|
|
11
|
+
const user = await getUser();
|
|
12
|
+
if (!user || (user.role !== 'partner' && user.role !== 'manager')) {
|
|
13
|
+
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { searchParams } = new URL(request.url);
|
|
17
|
+
const view = searchParams.get('view') || 'summary';
|
|
18
|
+
const days = parseInt(searchParams.get('days') || '30', 10);
|
|
19
|
+
const entityType = searchParams.get('entity_type');
|
|
20
|
+
const entityId = searchParams.get('entity_id');
|
|
21
|
+
const userId = searchParams.get('user_id');
|
|
22
|
+
const searchTerm = searchParams.get('search');
|
|
23
|
+
const limit = parseInt(searchParams.get('limit') || '100', 10);
|
|
24
|
+
|
|
25
|
+
const nowTs = Math.floor(Date.now() / 1000);
|
|
26
|
+
const startTs = nowTs - (days * 86400);
|
|
27
|
+
|
|
28
|
+
let data;
|
|
29
|
+
switch (view) {
|
|
30
|
+
case 'summary': {
|
|
31
|
+
const stats = getPermissionAuditStats();
|
|
32
|
+
const actionBreakdown = getPermissionAuditBreakdown('action');
|
|
33
|
+
const reasonBreakdown = getPermissionAuditBreakdown('reason_code');
|
|
34
|
+
const changes = getPermissionAuditByDateRange(startTs, nowTs, limit);
|
|
35
|
+
data = {
|
|
36
|
+
period_days: days,
|
|
37
|
+
total_changes: changes.length,
|
|
38
|
+
stats: { ...stats, unique_users: stats.unique_users, entity_types: stats.entity_types },
|
|
39
|
+
by_action: actionBreakdown.reduce((a, i) => { a[i.action] = i.count; return a; }, {}),
|
|
40
|
+
by_reason: reasonBreakdown.reduce((a, i) => { a[i.reason_code] = i.count; return a; }, {}),
|
|
41
|
+
};
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
case 'recent':
|
|
45
|
+
data = { period: `Last ${days} days`, changes: getPermissionAuditByDateRange(startTs, nowTs, limit) };
|
|
46
|
+
break;
|
|
47
|
+
case 'user':
|
|
48
|
+
if (!userId) return NextResponse.json({ error: 'user_id required' }, { status: 400 });
|
|
49
|
+
data = { user_id: userId, changes: getPermissionAuditTrail({ userId, limit }) };
|
|
50
|
+
break;
|
|
51
|
+
case 'entity':
|
|
52
|
+
if (!entityType || !entityId) return NextResponse.json({ error: 'entity_type and entity_id required' }, { status: 400 });
|
|
53
|
+
data = {
|
|
54
|
+
entity_type: entityType, entity_id: entityId,
|
|
55
|
+
timeline: getPermissionAuditTrail({ entityType, entityId, limit }).map(c => ({
|
|
56
|
+
...c, timestamp_iso: new Date(c.timestamp * 1000).toISOString(),
|
|
57
|
+
diff: c.old_permissions && c.new_permissions ? getPermissionDiff(c.old_permissions, c.new_permissions) : null,
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
break;
|
|
61
|
+
case 'search':
|
|
62
|
+
if (!searchTerm) return NextResponse.json({ error: 'search parameter required' }, { status: 400 });
|
|
63
|
+
data = { search_term: searchTerm, results: searchPermissionAudit(searchTerm, limit) };
|
|
64
|
+
break;
|
|
65
|
+
case 'roles':
|
|
66
|
+
data = { changes: getPermissionAuditTrail({ limit }).filter(c => c.action === 'role_change') };
|
|
67
|
+
break;
|
|
68
|
+
default:
|
|
69
|
+
return NextResponse.json({ error: `Unknown view: ${view}` }, { status: 400 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return NextResponse.json({ success: true, view, data });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error('[AuditDashboardAPI] GET error:', error);
|
|
75
|
+
return NextResponse.json({ error: 'Failed to retrieve audit dashboard data', details: error.message }, { status: 500 });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { NextResponse } from '@/lib/next-polyfills';
|
|
2
|
+
import { requireAuth } from '@/lib/auth-middleware';
|
|
3
|
+
import { searchLogs, getLogStats, rotateLogsOlderThan } from '@/lib/audit-logger-enhanced';
|
|
4
|
+
import { withErrorHandler } from '@/lib/with-error-handler';
|
|
5
|
+
|
|
6
|
+
export const GET = withErrorHandler(async (request) => {
|
|
7
|
+
const user = await requireAuth();
|
|
8
|
+
if (user.role !== 'admin' && user.role !== 'partner') {
|
|
9
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const url = new URL(request.url);
|
|
13
|
+
const filters = {
|
|
14
|
+
level: url.searchParams.get('level'),
|
|
15
|
+
operation: url.searchParams.get('operation'),
|
|
16
|
+
entityType: url.searchParams.get('entityType'),
|
|
17
|
+
entityId: url.searchParams.get('entityId'),
|
|
18
|
+
userId: url.searchParams.get('userId'),
|
|
19
|
+
action: url.searchParams.get('action'),
|
|
20
|
+
searchText: url.searchParams.get('q'),
|
|
21
|
+
fromDate: url.searchParams.get('from') ? parseInt(url.searchParams.get('from')) : Math.floor(Date.now() / 1000) - (7 * 24 * 60 * 60),
|
|
22
|
+
toDate: url.searchParams.get('to') ? parseInt(url.searchParams.get('to')) : Math.floor(Date.now() / 1000),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const page = parseInt(url.searchParams.get('page') || '1');
|
|
26
|
+
const pageSize = parseInt(url.searchParams.get('pageSize') || '100');
|
|
27
|
+
|
|
28
|
+
const result = searchLogs(filters, page, pageSize);
|
|
29
|
+
return NextResponse.json(result);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const POST = withErrorHandler(async (request) => {
|
|
33
|
+
const user = await requireAuth();
|
|
34
|
+
if (user.role !== 'admin') {
|
|
35
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const body = await request.json();
|
|
39
|
+
if (body.action === 'rotate') {
|
|
40
|
+
const daysOld = body.daysOld || 90;
|
|
41
|
+
const result = rotateLogsOlderThan(daysOld);
|
|
42
|
+
return NextResponse.json({ success: true, archived: result.archived, archiveId: result.archiveId });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
46
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NextResponse, cookies } from '@/lib/next-polyfills';
|
|
2
|
+
import { lucia } from '@/engine.server';
|
|
3
|
+
import { getPermissionAuditById, getPermissionDiff } from '@/lib/audit-logger';
|
|
4
|
+
|
|
5
|
+
async function getUser() {
|
|
6
|
+
const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null;
|
|
7
|
+
if (!sessionId) return null;
|
|
8
|
+
const { user } = await lucia.validateSession(sessionId);
|
|
9
|
+
return user;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function GET(request, context) {
|
|
13
|
+
try {
|
|
14
|
+
const user = await getUser();
|
|
15
|
+
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
16
|
+
|
|
17
|
+
const params = await context.params;
|
|
18
|
+
const auditId = params.id;
|
|
19
|
+
if (!auditId) return NextResponse.json({ error: 'Audit ID is required' }, { status: 400 });
|
|
20
|
+
|
|
21
|
+
const audit = getPermissionAuditById(auditId);
|
|
22
|
+
if (!audit) return NextResponse.json({ error: 'Audit record not found' }, { status: 404 });
|
|
23
|
+
|
|
24
|
+
if (user.role === 'clerk' && audit.user_id !== user.id) return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
|
|
25
|
+
if (user.role === 'client_admin' || user.role === 'client_user') return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
|
|
26
|
+
|
|
27
|
+
const diff = audit.old_permissions && audit.new_permissions ? getPermissionDiff(audit.old_permissions, audit.new_permissions) : null;
|
|
28
|
+
|
|
29
|
+
return NextResponse.json({
|
|
30
|
+
success: true,
|
|
31
|
+
data: { ...audit, timestamp_iso: new Date(audit.timestamp * 1000).toISOString(), diff },
|
|
32
|
+
});
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error('[AuditDetailAPI] GET error:', error);
|
|
35
|
+
return NextResponse.json({ error: 'Failed to retrieve audit record', details: error.message }, { status: 500 });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { NextResponse } from '@/lib/next-polyfills';
|
|
2
|
+
import { lucia } from '@/engine.server';
|
|
3
|
+
import { cookies } from '@/lib/next-polyfills';
|
|
4
|
+
import {
|
|
5
|
+
logPermissionChange, getPermissionAuditTrail, searchPermissionAudit,
|
|
6
|
+
getPermissionAuditByDateRange, exportPermissionAuditCSV,
|
|
7
|
+
} from '@/lib/audit-logger';
|
|
8
|
+
|
|
9
|
+
async function getUser() {
|
|
10
|
+
const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null;
|
|
11
|
+
if (!sessionId) return null;
|
|
12
|
+
const { user } = await lucia.validateSession(sessionId);
|
|
13
|
+
return user;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function checkAuditPermission(user) {
|
|
17
|
+
if (!user) return { allowed: false, error: 'Unauthorized' };
|
|
18
|
+
if (user.role === 'partner' || user.role === 'manager') return { allowed: true, scope: 'all' };
|
|
19
|
+
if (user.role === 'clerk') return { allowed: true, scope: 'own' };
|
|
20
|
+
return { allowed: false, error: 'Permission denied' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function GET(request) {
|
|
24
|
+
try {
|
|
25
|
+
const user = await getUser();
|
|
26
|
+
const permCheck = checkAuditPermission(user);
|
|
27
|
+
if (!permCheck.allowed) return NextResponse.json({ error: permCheck.error }, { status: 403 });
|
|
28
|
+
|
|
29
|
+
const { searchParams } = new URL(request.url);
|
|
30
|
+
const search = searchParams.get('search');
|
|
31
|
+
const startDate = searchParams.get('start_date');
|
|
32
|
+
const endDate = searchParams.get('end_date');
|
|
33
|
+
const action = searchParams.get('action');
|
|
34
|
+
const limit = parseInt(searchParams.get('limit') || '100', 10);
|
|
35
|
+
const offset = parseInt(searchParams.get('offset') || '0', 10);
|
|
36
|
+
const format = searchParams.get('format');
|
|
37
|
+
|
|
38
|
+
let results;
|
|
39
|
+
if (search) {
|
|
40
|
+
results = searchPermissionAudit(search, limit);
|
|
41
|
+
} else if (startDate && endDate) {
|
|
42
|
+
results = getPermissionAuditByDateRange(parseInt(startDate, 10), parseInt(endDate, 10), limit);
|
|
43
|
+
} else {
|
|
44
|
+
const filters = {};
|
|
45
|
+
const entityType = searchParams.get('entity_type');
|
|
46
|
+
const entityId = searchParams.get('entity_id');
|
|
47
|
+
const userId = searchParams.get('user_id');
|
|
48
|
+
const affectedUserId = searchParams.get('affected_user_id');
|
|
49
|
+
if (entityType) filters.entityType = entityType;
|
|
50
|
+
if (entityId) filters.entityId = entityId;
|
|
51
|
+
if (permCheck.scope === 'own') filters.userId = user.id;
|
|
52
|
+
else if (userId) filters.userId = userId;
|
|
53
|
+
if (affectedUserId) filters.affectedUserId = affectedUserId;
|
|
54
|
+
filters.limit = limit;
|
|
55
|
+
filters.offset = offset;
|
|
56
|
+
results = getPermissionAuditTrail(filters);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (format === 'csv') {
|
|
60
|
+
const csv = await exportPermissionAuditCSV({});
|
|
61
|
+
return new NextResponse(csv, { headers: { 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="permission_audit.csv"' } });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return NextResponse.json({ success: true, data: results, count: results.length, scope: permCheck.scope });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error('[AuditAPI] GET error:', error);
|
|
67
|
+
return NextResponse.json({ error: 'Failed to retrieve audit trail', details: error.message }, { status: 500 });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function POST(request) {
|
|
72
|
+
try {
|
|
73
|
+
const user = await getUser();
|
|
74
|
+
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
75
|
+
|
|
76
|
+
const body = await request.json();
|
|
77
|
+
const { entityType, entityId, action, oldPermissions, newPermissions, reason, reasonCode, affectedUserId, metadata } = body;
|
|
78
|
+
if (!entityType || !entityId || !action) return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
|
79
|
+
|
|
80
|
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || null;
|
|
81
|
+
const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null;
|
|
82
|
+
|
|
83
|
+
const auditId = logPermissionChange({
|
|
84
|
+
userId: user.id, entityType, entityId, action, oldPermissions, newPermissions,
|
|
85
|
+
reason, reasonCode: reasonCode || 'other', affectedUserId, ipAddress, sessionId, metadata,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return NextResponse.json({ success: true, auditId });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error('[AuditAPI] POST error:', error);
|
|
91
|
+
return NextResponse.json({ error: 'Failed to log permission change', details: error.message }, { status: 500 });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextResponse } from '@/lib/next-polyfills';
|
|
2
|
+
import { getUser, setCurrentRequest } from '@/engine.server';
|
|
3
|
+
import { getPermissionAuditStats, getPermissionAuditBreakdown } from '@/lib/audit-logger';
|
|
4
|
+
|
|
5
|
+
export async function GET(request) {
|
|
6
|
+
setCurrentRequest(request);
|
|
7
|
+
try {
|
|
8
|
+
const user = await getUser();
|
|
9
|
+
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
10
|
+
if (user.role !== 'partner' && user.role !== 'manager') return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
|
|
11
|
+
|
|
12
|
+
const stats = getPermissionAuditStats();
|
|
13
|
+
const actionBreakdown = getPermissionAuditBreakdown('action');
|
|
14
|
+
const reasonCodeBreakdown = getPermissionAuditBreakdown('reason_code');
|
|
15
|
+
|
|
16
|
+
return NextResponse.json({
|
|
17
|
+
success: true,
|
|
18
|
+
stats: {
|
|
19
|
+
...stats,
|
|
20
|
+
earliest_change: stats.earliest_change ? new Date(stats.earliest_change * 1000).toISOString() : null,
|
|
21
|
+
latest_change: stats.latest_change ? new Date(stats.latest_change * 1000).toISOString() : null,
|
|
22
|
+
},
|
|
23
|
+
breakdown: { by_action: actionBreakdown, by_reason_code: reasonCodeBreakdown },
|
|
24
|
+
});
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error('[AuditStatsAPI] GET error:', error);
|
|
27
|
+
return NextResponse.json({ error: 'Failed to retrieve audit statistics', details: error.message }, { status: 500 });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { requireAuth, requirePermission } from '@/lib/auth-middleware';
|
|
2
|
+
import { getAuditHistory, getActionStats, getUserStats } from '@/lib/audit-logger';
|
|
3
|
+
import { getSpec } from '@/config/spec-helpers';
|
|
4
|
+
import { paginated, ok } from '@/lib/response-formatter';
|
|
5
|
+
import { withErrorHandler } from '@/lib/with-error-handler';
|
|
6
|
+
import { AppError } from '@/lib/error-handler';
|
|
7
|
+
import { HTTP } from '@/config/constants';
|
|
8
|
+
import { parse as parseQuery } from '@/lib/query-string-adapter';
|
|
9
|
+
|
|
10
|
+
export const GET = withErrorHandler(async (request) => {
|
|
11
|
+
const user = await requireAuth();
|
|
12
|
+
const url = new URL(request.url);
|
|
13
|
+
|
|
14
|
+
const entityType = url.searchParams.get('entityType');
|
|
15
|
+
const entityId = url.searchParams.get('entityId');
|
|
16
|
+
const userId = url.searchParams.get('userId');
|
|
17
|
+
const action = url.searchParams.get('action');
|
|
18
|
+
const fromDate = url.searchParams.get('fromDate') ? parseInt(url.searchParams.get('fromDate')) : null;
|
|
19
|
+
const toDate = url.searchParams.get('toDate') ? parseInt(url.searchParams.get('toDate')) : null;
|
|
20
|
+
const page = url.searchParams.get('page') ? parseInt(url.searchParams.get('page')) : 1;
|
|
21
|
+
const pageSize = url.searchParams.get('pageSize') ? parseInt(url.searchParams.get('pageSize')) : 50;
|
|
22
|
+
const stats = url.searchParams.get('stats');
|
|
23
|
+
|
|
24
|
+
if (page < 1) {
|
|
25
|
+
throw new AppError('page must be >= 1', 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
26
|
+
}
|
|
27
|
+
if (pageSize < 1 || pageSize > 200) {
|
|
28
|
+
throw new AppError('pageSize must be between 1 and 200', 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (stats === 'true') {
|
|
32
|
+
if (!fromDate || !toDate) {
|
|
33
|
+
throw new AppError('fromDate and toDate required for stats', 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
34
|
+
}
|
|
35
|
+
const actionStats = getActionStats(fromDate, toDate);
|
|
36
|
+
const userStats = getUserStats(fromDate, toDate);
|
|
37
|
+
return ok({
|
|
38
|
+
actionStats,
|
|
39
|
+
userStats
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const filters = {};
|
|
44
|
+
if (entityType) filters.entityType = entityType;
|
|
45
|
+
if (entityId) filters.entityId = entityId;
|
|
46
|
+
if (userId) filters.userId = userId;
|
|
47
|
+
if (action) filters.action = action;
|
|
48
|
+
if (fromDate) filters.fromDate = fromDate;
|
|
49
|
+
if (toDate) filters.toDate = toDate;
|
|
50
|
+
|
|
51
|
+
const { items, pagination } = getAuditHistory(filters, page, pageSize);
|
|
52
|
+
|
|
53
|
+
const enriched = items.map(log => ({
|
|
54
|
+
...log,
|
|
55
|
+
timestamp: log.createdAt,
|
|
56
|
+
changes: log.beforeState && log.afterState ? findChanges(log.beforeState, log.afterState) : []
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
return paginated(enriched, pagination);
|
|
60
|
+
}, 'GET /api/audit');
|
|
61
|
+
|
|
62
|
+
function findChanges(before, after) {
|
|
63
|
+
const changes = [];
|
|
64
|
+
const allKeys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]);
|
|
65
|
+
|
|
66
|
+
for (const key of allKeys) {
|
|
67
|
+
const beforeVal = before?.[key];
|
|
68
|
+
const afterVal = after?.[key];
|
|
69
|
+
if (JSON.stringify(beforeVal) !== JSON.stringify(afterVal)) {
|
|
70
|
+
changes.push({
|
|
71
|
+
field: key,
|
|
72
|
+
from: beforeVal,
|
|
73
|
+
to: afterVal
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return changes;
|
|
79
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { NextResponse } from '@/lib/next-polyfills';
|
|
2
|
+
import { requireAuth } from '@/lib/auth-middleware';
|
|
3
|
+
import { getLogStats } from '@/lib/audit-logger-enhanced';
|
|
4
|
+
import { withErrorHandler } from '@/lib/with-error-handler';
|
|
5
|
+
|
|
6
|
+
export const GET = withErrorHandler(async (request) => {
|
|
7
|
+
const user = await requireAuth();
|
|
8
|
+
if (user.role !== 'admin' && user.role !== 'partner') {
|
|
9
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const url = new URL(request.url);
|
|
13
|
+
const fromDate = url.searchParams.get('from') ? parseInt(url.searchParams.get('from')) : Math.floor(Date.now() / 1000) - (24 * 60 * 60);
|
|
14
|
+
const toDate = url.searchParams.get('to') ? parseInt(url.searchParams.get('to')) : Math.floor(Date.now() / 1000);
|
|
15
|
+
|
|
16
|
+
const stats = getLogStats(fromDate, toDate);
|
|
17
|
+
return NextResponse.json({ success: true, stats, period: { from: fromDate, to: toDate } });
|
|
18
|
+
});
|