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,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collaborator Role Service - Role-based access control for review collaborators
|
|
3
|
+
* Extracted from moonlanding/src/services/collaborator-role.service.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { list, get, update, create } from '../lib/query-engine.js';
|
|
7
|
+
import { AppError } from '../lib/error-handler.js';
|
|
8
|
+
import { HTTP } from '../config/constants.js';
|
|
9
|
+
|
|
10
|
+
const COLLABORATOR_ROLE_PERMISSIONS = {
|
|
11
|
+
viewer: ['view', 'view_highlights', 'view_pdfs'],
|
|
12
|
+
commenter: ['view', 'view_highlights', 'view_pdfs', 'add_notes', 'add_comments'],
|
|
13
|
+
reviewer: [
|
|
14
|
+
'view', 'view_highlights', 'view_pdfs', 'add_notes', 'add_comments',
|
|
15
|
+
'edit_highlights', 'resolve_highlights', 'reopen_highlights', 'create_highlights',
|
|
16
|
+
'delete_own_highlights', 'manage_highlights'
|
|
17
|
+
],
|
|
18
|
+
manager: [
|
|
19
|
+
'view', 'view_highlights', 'view_pdfs', 'add_notes', 'add_comments',
|
|
20
|
+
'edit_highlights', 'resolve_highlights', 'reopen_highlights', 'create_highlights',
|
|
21
|
+
'delete_highlights', 'manage_highlights', 'manage_collaborators', 'manage_flags',
|
|
22
|
+
'manage_templates', 'manage_checklists', 'archive', 'assign_roles', 'approve_changes'
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get active collaborator role
|
|
28
|
+
* @param {string} collaboratorId
|
|
29
|
+
* @returns {object|null}
|
|
30
|
+
*/
|
|
31
|
+
export function getCollaboratorRole(collaboratorId) {
|
|
32
|
+
if (!collaboratorId) return null;
|
|
33
|
+
const collaborator = get('collaborator', collaboratorId);
|
|
34
|
+
if (!collaborator) return null;
|
|
35
|
+
|
|
36
|
+
if (collaborator.primary_role_id) {
|
|
37
|
+
const role = get('collaborator_role', collaborator.primary_role_id);
|
|
38
|
+
if (role && role.is_active) return role;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const roles = list('collaborator_role')
|
|
42
|
+
.filter(r => r.collaborator_id === collaboratorId && r.is_active);
|
|
43
|
+
|
|
44
|
+
if (roles.length === 0) return null;
|
|
45
|
+
|
|
46
|
+
roles.sort((a, b) => b.assigned_at - a.assigned_at);
|
|
47
|
+
const activeRole = roles[0];
|
|
48
|
+
|
|
49
|
+
if (collaborator.primary_role_id !== activeRole.id) {
|
|
50
|
+
update('collaborator', collaboratorId, {
|
|
51
|
+
primary_role_id: activeRole.id,
|
|
52
|
+
is_manager: activeRole.role_type === 'manager'
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return activeRole;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if collaborator has permission for action
|
|
60
|
+
* @param {string} collaboratorId
|
|
61
|
+
* @param {string} permission
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
export function hasCollaboratorPermission(collaboratorId, permission) {
|
|
65
|
+
const role = getCollaboratorRole(collaboratorId);
|
|
66
|
+
if (!role) return false;
|
|
67
|
+
const perms = COLLABORATOR_ROLE_PERMISSIONS[role.role_type];
|
|
68
|
+
return perms ? perms.includes(permission) : false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check collaborator access for specific action (with optional record)
|
|
73
|
+
* @param {string} collaboratorId
|
|
74
|
+
* @param {string} action
|
|
75
|
+
* @param {object} [record]
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
export function checkCollaboratorAccess(collaboratorId, action, record = null) {
|
|
79
|
+
const role = getCollaboratorRole(collaboratorId);
|
|
80
|
+
if (!role) return false;
|
|
81
|
+
const perms = COLLABORATOR_ROLE_PERMISSIONS[role.role_type];
|
|
82
|
+
if (!perms) return false;
|
|
83
|
+
if (perms.includes(action)) return true;
|
|
84
|
+
if (action === 'delete_highlights' && perms.includes('delete_own_highlights') && record) {
|
|
85
|
+
const collaborator = get('collaborator', collaboratorId);
|
|
86
|
+
return record.created_by === collaborator?.user_id;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get role history for collaborator
|
|
93
|
+
* @param {string} collaboratorId
|
|
94
|
+
* @returns {Array}
|
|
95
|
+
*/
|
|
96
|
+
export function getCollaboratorRoleHistory(collaboratorId) {
|
|
97
|
+
if (!collaboratorId) return [];
|
|
98
|
+
return list('collaborator_role')
|
|
99
|
+
.filter(r => r.collaborator_id === collaboratorId)
|
|
100
|
+
.sort((a, b) => b.assigned_at - a.assigned_at);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get permissions for a role type
|
|
105
|
+
* @param {string} roleType
|
|
106
|
+
* @returns {Array<string>}
|
|
107
|
+
*/
|
|
108
|
+
export function getCollaboratorRolePermissions(roleType) {
|
|
109
|
+
return COLLABORATOR_ROLE_PERMISSIONS[roleType] || [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if user role can assign target role
|
|
114
|
+
* @param {string} userRole
|
|
115
|
+
* @param {string} targetRoleType
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
export function canAssignRole(userRole, targetRoleType) {
|
|
119
|
+
return ['partner', 'manager'].includes(userRole);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Add a collaborator to a review
|
|
124
|
+
* @param {string} reviewId
|
|
125
|
+
* @param {string} email
|
|
126
|
+
* @param {object} options
|
|
127
|
+
* @returns {object}
|
|
128
|
+
*/
|
|
129
|
+
export function addCollaborator(reviewId, email, options = {}) {
|
|
130
|
+
const { expiresAt, createdBy = 'system', reason = '' } = options;
|
|
131
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
132
|
+
const isPermanent = !expiresAt;
|
|
133
|
+
|
|
134
|
+
if (expiresAt) {
|
|
135
|
+
const maxAllowed = nowSeconds + (30 * 24 * 60 * 60);
|
|
136
|
+
if (expiresAt <= nowSeconds) throw new Error('Expiry date must be in the future');
|
|
137
|
+
if (expiresAt > maxAllowed) throw new Error('Expiry date cannot exceed 30 days from now');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return create('collaborator', {
|
|
141
|
+
review_id: reviewId,
|
|
142
|
+
email,
|
|
143
|
+
expires_at: expiresAt || null,
|
|
144
|
+
is_permanent: isPermanent,
|
|
145
|
+
created_at: nowSeconds,
|
|
146
|
+
created_by: createdBy,
|
|
147
|
+
reason,
|
|
148
|
+
access_type: isPermanent ? 'permanent' : 'temporary',
|
|
149
|
+
}, { id: createdBy, role: 'partner' });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get collaborators for a review
|
|
154
|
+
* @param {string} reviewId
|
|
155
|
+
* @returns {Array}
|
|
156
|
+
*/
|
|
157
|
+
export function getReviewCollaborators(reviewId) {
|
|
158
|
+
const collaborators = list('collaborator', { review_id: reviewId });
|
|
159
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
160
|
+
|
|
161
|
+
return collaborators.map(c => ({
|
|
162
|
+
id: c.id,
|
|
163
|
+
email: c.email,
|
|
164
|
+
accessType: c.access_type,
|
|
165
|
+
isPermanent: c.is_permanent,
|
|
166
|
+
expiresAt: c.expires_at,
|
|
167
|
+
daysUntilExpiry: c.expires_at ? Math.ceil((c.expires_at - nowSeconds) / 86400) : null,
|
|
168
|
+
isExpired: !c.is_permanent && c.expires_at && c.expires_at <= nowSeconds,
|
|
169
|
+
createdAt: c.created_at,
|
|
170
|
+
createdBy: c.created_by,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Revoke collaborator access
|
|
176
|
+
* @param {string} collaboratorId
|
|
177
|
+
* @param {string} [reason='manual_revoke']
|
|
178
|
+
* @param {string} [revokedBy='system']
|
|
179
|
+
* @returns {boolean}
|
|
180
|
+
*/
|
|
181
|
+
export function revokeCollaborator(collaboratorId, reason = 'manual_revoke', revokedBy = 'system') {
|
|
182
|
+
const collaborator = get('collaborator', collaboratorId);
|
|
183
|
+
if (!collaborator) throw new Error('Collaborator not found');
|
|
184
|
+
|
|
185
|
+
update('collaborator', collaboratorId, {
|
|
186
|
+
revoked_at: Math.floor(Date.now() / 1000),
|
|
187
|
+
revoked_by: revokedBy,
|
|
188
|
+
revocation_reason: reason,
|
|
189
|
+
access_type: 'revoked',
|
|
190
|
+
}, { id: revokedBy, role: 'partner' });
|
|
191
|
+
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export default {
|
|
196
|
+
getCollaboratorRole,
|
|
197
|
+
hasCollaboratorPermission,
|
|
198
|
+
checkCollaboratorAccess,
|
|
199
|
+
getCollaboratorRoleHistory,
|
|
200
|
+
getCollaboratorRolePermissions,
|
|
201
|
+
canAssignRole,
|
|
202
|
+
addCollaborator,
|
|
203
|
+
getReviewCollaborators,
|
|
204
|
+
revokeCollaborator,
|
|
205
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email Sender - SMTP email delivery with templates
|
|
3
|
+
* Uses nodemailer
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import nodemailer from 'nodemailer';
|
|
7
|
+
import { buildConfig } from '../config/env.js';
|
|
8
|
+
|
|
9
|
+
let transporter = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Initialize email transporter
|
|
13
|
+
* @param {object} [config]
|
|
14
|
+
*/
|
|
15
|
+
export function initEmail(config = null) {
|
|
16
|
+
const cfg = config || buildConfig();
|
|
17
|
+
|
|
18
|
+
if (!cfg.email.smtp.user || !cfg.email.smtp.password) {
|
|
19
|
+
console.warn('[Email] SMTP credentials not configured');
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
transporter = nodemailer.createTransport({
|
|
24
|
+
host: cfg.email.smtp.host,
|
|
25
|
+
port: cfg.email.smtp.port,
|
|
26
|
+
secure: cfg.email.smtp.port === 465, // true for 465, false for other ports
|
|
27
|
+
auth: {
|
|
28
|
+
user: cfg.email.smtp.user,
|
|
29
|
+
pass: cfg.email.smtp.password,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return transporter;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get initialized transporter
|
|
38
|
+
* @returns {object}
|
|
39
|
+
*/
|
|
40
|
+
export function getTransporter() {
|
|
41
|
+
if (!transporter) initEmail();
|
|
42
|
+
return transporter;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Send email
|
|
47
|
+
* @param {object} options - { to, subject, text, html, attachments }
|
|
48
|
+
* @returns {Promise<object>}
|
|
49
|
+
*/
|
|
50
|
+
export async function sendEmail(options) {
|
|
51
|
+
const transporter = getTransporter();
|
|
52
|
+
if (!transporter) {
|
|
53
|
+
throw new Error('Email not configured');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const from = buildConfig().email.from;
|
|
57
|
+
|
|
58
|
+
const info = await transporter.sendMail({
|
|
59
|
+
from,
|
|
60
|
+
...options,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
console.log('[Email] Sent:', info.messageId);
|
|
64
|
+
return info;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Send templated email
|
|
69
|
+
* @param {string} templateName
|
|
70
|
+
* @param {string} to
|
|
71
|
+
* @param {object} context
|
|
72
|
+
* @returns {Promise<object>}
|
|
73
|
+
*/
|
|
74
|
+
export async function sendTemplatedEmail(templateName, to, context = {}) {
|
|
75
|
+
// Templates could be loaded from config or files
|
|
76
|
+
const templates = getTemplates();
|
|
77
|
+
const template = templates[templateName];
|
|
78
|
+
|
|
79
|
+
if (!template) {
|
|
80
|
+
throw new Error(`Email template not found: ${templateName}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const subject = template.subject.replace(/\{\{(\w+)\}\}/g, (_, key) => context[key] || '');
|
|
84
|
+
const text = template.text?.replace(/\{\{(\w+)\}\}/g, (_, key) => context[key] || '');
|
|
85
|
+
const html = template.html?.replace(/\{\{(\w+)\}\}/g, (_, key) => context[key] || '');
|
|
86
|
+
|
|
87
|
+
return sendEmail({ to, subject, text, html });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Simple templates (could be loaded from config)
|
|
92
|
+
* @returns {object}
|
|
93
|
+
*/
|
|
94
|
+
function getTemplates() {
|
|
95
|
+
return {
|
|
96
|
+
notification: {
|
|
97
|
+
subject: 'Notification from {{appName}}',
|
|
98
|
+
text: '{{message}}\n\n— {{appName}}',
|
|
99
|
+
},
|
|
100
|
+
invitation: {
|
|
101
|
+
subject: 'You\'ve been invited to {{appName}}',
|
|
102
|
+
text: '{{inviter}} has invited you to join {{appName}}.\n\nSign up: {{url}}',
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification Engine - In-app notifications, email triggers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getTransporter, sendEmail } from './email-sender.js';
|
|
6
|
+
import { getConfigEngineSync } from '../lib/config-generator-engine.js';
|
|
7
|
+
import { executeHook } from '../lib/hook-engine.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get notification template from config
|
|
11
|
+
*/
|
|
12
|
+
export function getNotificationTemplate(name) {
|
|
13
|
+
const engine = getConfigEngineSync();
|
|
14
|
+
return engine.generateNotificationHandler(name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create notification record
|
|
19
|
+
*/
|
|
20
|
+
export async function createNotification(notification) {
|
|
21
|
+
const { create } = await import('../lib/query-engine-write.js');
|
|
22
|
+
return create('notification', {
|
|
23
|
+
...notification,
|
|
24
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
25
|
+
read_at: null,
|
|
26
|
+
}, { id: notification.created_by || 'system', role: 'system' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Send notification to user (in-app + optional email)
|
|
31
|
+
*/
|
|
32
|
+
export async function sendNotification(type, userId, context = {}, options = {}) {
|
|
33
|
+
const template = getNotificationTemplate(type);
|
|
34
|
+
if (!template) {
|
|
35
|
+
console.warn(`[Notification] Template not found: ${type}`);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const title = interpolate(template.title, context);
|
|
40
|
+
const message = interpolate(template.message, context);
|
|
41
|
+
|
|
42
|
+
// Create notification record
|
|
43
|
+
const notification = await createNotification({
|
|
44
|
+
type,
|
|
45
|
+
user_id: userId,
|
|
46
|
+
title,
|
|
47
|
+
message,
|
|
48
|
+
data: context,
|
|
49
|
+
entity_type: options.entityType,
|
|
50
|
+
entity_id: options.entityId,
|
|
51
|
+
created_by: context.userId || 'system',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Send email if enabled
|
|
55
|
+
if (options.sendEmail !== false) {
|
|
56
|
+
try {
|
|
57
|
+
const { getUser } = await import('../engine.server.js');
|
|
58
|
+
const user = await getUser(userId);
|
|
59
|
+
if (user?.email) {
|
|
60
|
+
await sendEmail({
|
|
61
|
+
to: user.email,
|
|
62
|
+
subject: `[${context.appName || 'App'}] ${title}`,
|
|
63
|
+
text: `${message}\n\n---\nView in app: ${context.url || '/'}`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error('[Notification] Email failed:', err.message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
executeHook(`notification:${type}`, {
|
|
72
|
+
notification,
|
|
73
|
+
user: { id: userId },
|
|
74
|
+
context,
|
|
75
|
+
}).catch(console.error);
|
|
76
|
+
|
|
77
|
+
return notification;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Mark notification as read
|
|
82
|
+
*/
|
|
83
|
+
export async function markNotificationRead(notificationId, userId) {
|
|
84
|
+
const { update } = await import('../lib/query-engine-write.js');
|
|
85
|
+
return update('notification', notificationId, {
|
|
86
|
+
read_at: Math.floor(Date.now() / 1000),
|
|
87
|
+
}, { id: userId, role: 'user' });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get unread count for user
|
|
92
|
+
*/
|
|
93
|
+
export async function getUnreadCount(userId) {
|
|
94
|
+
const { list } = await import('../lib/query-engine.js');
|
|
95
|
+
return list('notification', {
|
|
96
|
+
user_id: userId,
|
|
97
|
+
read_at: null,
|
|
98
|
+
}).length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Interpolate {{key}} in template strings
|
|
103
|
+
*/
|
|
104
|
+
function interpolate(template, context) {
|
|
105
|
+
if (!template) return '';
|
|
106
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
107
|
+
const val = context[key];
|
|
108
|
+
return val !== undefined ? String(val) : `{{${key}}}`;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission Service - Authorization logic
|
|
3
|
+
* Adapted from moonlanding/src/services/permission.service.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getCollaboratorRole, checkCollaboratorAccess } from '../services/collaborator-role.service.js';
|
|
7
|
+
import { PermissionError } from './error-handler.js';
|
|
8
|
+
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
9
|
+
|
|
10
|
+
class PermissionService {
|
|
11
|
+
/**
|
|
12
|
+
* Check if user can perform action on entity spec
|
|
13
|
+
* @param {object} user
|
|
14
|
+
* @param {object} spec
|
|
15
|
+
* @param {string} action
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
can(user, spec, action) {
|
|
19
|
+
if (!user) return false;
|
|
20
|
+
if (!spec?.access?.[action]) return true; // No restriction defined = allow
|
|
21
|
+
return spec.access[action].includes(user.role);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Require permission (throw if denied)
|
|
26
|
+
* @param {object} user
|
|
27
|
+
* @param {object} spec
|
|
28
|
+
* @param {string} action
|
|
29
|
+
*/
|
|
30
|
+
require(user, spec, action) {
|
|
31
|
+
if (!this.can(user, spec, action)) {
|
|
32
|
+
throw new PermissionError(`Cannot ${action} ${spec?.name || 'unknown'}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check field-level access
|
|
38
|
+
* @param {object} user
|
|
39
|
+
* @param {object} spec
|
|
40
|
+
* @param {string} fieldName
|
|
41
|
+
* @param {string} action - 'view' or 'edit'
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
checkFieldAccess(user, spec, fieldName, action) {
|
|
45
|
+
if (!user) return false;
|
|
46
|
+
const perm = spec.fieldPermissions?.[fieldName];
|
|
47
|
+
if (!perm) return true;
|
|
48
|
+
const allowed = perm[action];
|
|
49
|
+
if (allowed === 'all') return true;
|
|
50
|
+
return Array.isArray(allowed) && allowed.includes(user.role);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check row-level access
|
|
55
|
+
* @param {object} user
|
|
56
|
+
* @param {object} spec
|
|
57
|
+
* @param {object} record
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
checkRowAccess(user, spec, record) {
|
|
61
|
+
if (!user) return false;
|
|
62
|
+
const rowAccess = spec.rowAccess || spec.row_access;
|
|
63
|
+
if (!rowAccess) return true;
|
|
64
|
+
|
|
65
|
+
const roles = getConfigEngineSync().getRoles();
|
|
66
|
+
const partnerRole = Object.keys(roles).find(r => roles[r].hierarchy === 0);
|
|
67
|
+
const clientAdminRole = Object.keys(roles).find(r => r.includes('client') && r.includes('admin'));
|
|
68
|
+
const clientUserRole = Object.keys(roles).find(r => r === 'client_user');
|
|
69
|
+
|
|
70
|
+
const scope = rowAccess.scope || rowAccess;
|
|
71
|
+
|
|
72
|
+
if (scope === 'team' && record.team_id && user.team_id && record.team_id !== user.team_id) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (scope === 'assigned' && record.assigned_to && record.assigned_to !== user.id && user.role !== partnerRole) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (scope === 'assigned_or_team' && user.role !== partnerRole) {
|
|
81
|
+
const assignedMatch = record.assigned_to && record.assigned_to === user.id;
|
|
82
|
+
const teamMatch = record.team_id && user.team_id && record.team_id === user.team_id;
|
|
83
|
+
if (!assignedMatch && !teamMatch) return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (scope === 'client') {
|
|
87
|
+
if ((clientAdminRole && user.role === clientAdminRole) || (clientUserRole && user.role === clientUserRole)) {
|
|
88
|
+
if (record.client_id && user.client_id && record.client_id !== user.client_id) return false;
|
|
89
|
+
if (clientUserRole && user.role === clientUserRole && !this.checkAssignment(user, spec, record)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
} else if (record.client_id && user.client_ids && !user.client_ids.includes(record.client_id)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Filter records by row access
|
|
102
|
+
* @param {object} user
|
|
103
|
+
* @param {object} spec
|
|
104
|
+
* @param {Array} records
|
|
105
|
+
* @returns {Array}
|
|
106
|
+
*/
|
|
107
|
+
filterRecords(user, spec, records) {
|
|
108
|
+
if (!user || !Array.isArray(records)) return records;
|
|
109
|
+
return records.filter(r => this.checkRowAccess(user, spec, r));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Filter fields by field-level permissions
|
|
114
|
+
* @param {object} user
|
|
115
|
+
* @param {object} spec
|
|
116
|
+
* @param {object} record
|
|
117
|
+
* @returns {object}
|
|
118
|
+
*/
|
|
119
|
+
filterFields(user, spec, record) {
|
|
120
|
+
if (!user) return record;
|
|
121
|
+
const filtered = {};
|
|
122
|
+
for (const [key, value] of Object.entries(record)) {
|
|
123
|
+
if (spec.fields?.[key]?.hidden) continue;
|
|
124
|
+
if (this.checkFieldAccess(user, spec, key, 'view')) {
|
|
125
|
+
filtered[key] = value;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return filtered;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Enforce edit permissions on data
|
|
133
|
+
* @param {object} user
|
|
134
|
+
* @param {object} spec
|
|
135
|
+
* @param {object} data
|
|
136
|
+
*/
|
|
137
|
+
enforceEditPermissions(user, spec, data) {
|
|
138
|
+
if (!user) throw new PermissionError(`Cannot edit ${spec.name}`);
|
|
139
|
+
if (!this.can(user, spec, 'edit')) throw new PermissionError(`Cannot edit ${spec.name}`);
|
|
140
|
+
|
|
141
|
+
for (const field of Object.keys(data)) {
|
|
142
|
+
if (!this.checkFieldAccess(user, spec, field, 'edit')) {
|
|
143
|
+
throw new PermissionError(`Cannot edit ${spec.name}.${field}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if user owns record
|
|
150
|
+
* @param {object} user
|
|
151
|
+
* @param {object} spec
|
|
152
|
+
* @param {object} record
|
|
153
|
+
* @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
checkOwnership(user, spec, record) {
|
|
156
|
+
if (!record) return false;
|
|
157
|
+
return record.created_by === user.id || record.user_id === user.id;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check assignment for client access
|
|
162
|
+
* @param {object} user
|
|
163
|
+
* @param {object} spec
|
|
164
|
+
* @param {object} record
|
|
165
|
+
* @returns {boolean}
|
|
166
|
+
*/
|
|
167
|
+
checkAssignment(user, spec, record) {
|
|
168
|
+
// Simplified - check if user is assigned to record
|
|
169
|
+
return record.assigned_to === user.id || record.team_id === user.team_id;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check collaborator-style permission
|
|
174
|
+
*/
|
|
175
|
+
hasCollaboratorPermission(collaboratorId, permission) {
|
|
176
|
+
return getCollaboratorRole(collaboratorId)?.permissions?.includes(permission);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const permissionService = new PermissionService();
|
|
181
|
+
export default permissionService;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { statusLabel } from '@/ui/renderer.js';
|
|
2
|
+
import { page } from '@/ui/layout.js';
|
|
3
|
+
import { fmtDate } from '@/ui/render-helpers.js';
|
|
4
|
+
|
|
5
|
+
function resultCard(item, entityType) {
|
|
6
|
+
const sts = item.status ? statusLabel(item.status) : '';
|
|
7
|
+
const title = item.name || item.title || 'Untitled';
|
|
8
|
+
const subtitle = item.client_name || item.engagement_name || item.email || '';
|
|
9
|
+
const date = fmtDate(item.created_at);
|
|
10
|
+
const typeLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1);
|
|
11
|
+
return `<div class="card-clean" style="margin-bottom:8px;cursor:pointer" data-navigate="/${entityType}/${item.id}"><div class="card-clean-body" style="padding:0.75rem"><div class="flex items-start justify-between"><div class="flex-1"><div class="flex items-center gap-2 mb-1"><span class="badge badge-sm bg-gray-100 text-gray-600">${typeLabel}</span>${sts}</div><div class="font-medium">${title}</div>${subtitle ? `<div class="text-xs text-gray-500 mt-0.5">${subtitle}</div>` : ''}</div><div class="text-xs text-gray-400">${date}</div></div></div></div>`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function filterPanel(teams, stages) {
|
|
15
|
+
const teamOpts = teams.map(t => `<option value="${t.id}">${t.name}</option>`).join('');
|
|
16
|
+
const stageOpts = stages.map(s => `<option value="${s}">${s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}</option>`).join('');
|
|
17
|
+
return `<div class="card-clean" style="margin-bottom:1.5rem"><div class="card-clean-body"><div class="grid grid-cols-1 md:grid-cols-4 gap-3"><div><label class="text-xs font-medium text-gray-600 block mb-1" for="search-query">Search</label><input type="text" id="search-query" class="input input-bordered input-sm w-full" placeholder="Search across all entities..."/></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-entity">Entity Type</label><select id="filter-entity" class="select select-bordered select-sm w-full"><option value="">All Types</option><option value="engagement">Engagements</option><option value="client">Clients</option><option value="rfi">RFIs</option><option value="review">Reviews</option><option value="user">Users</option></select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-status">Status</label><select id="filter-status" class="select select-bordered select-sm w-full"><option value="">All Statuses</option><option value="active">Active</option><option value="pending">Pending</option><option value="completed">Completed</option><option value="archived">Archived</option></select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-stage">Stage</label><select id="filter-stage" class="select select-bordered select-sm w-full"><option value="">All Stages</option>${stageOpts}</select></div></div><div class="grid grid-cols-1 md:grid-cols-4 gap-3 mt-3"><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-team">Team</label><select id="filter-team" class="select select-bordered select-sm w-full"><option value="">All Teams</option>${teamOpts}</select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-from">Date From</label><input type="date" id="filter-from" class="input input-bordered input-sm w-full"/></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-to">Date To</label><input type="date" id="filter-to" class="input input-bordered input-sm w-full"/></div><div class="flex items-end"><button class="btn btn-primary btn-sm w-full" data-action="doSearch">Search</button></div></div></div></div>`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderAdvancedSearch(user, results = {}, options = {}) {
|
|
21
|
+
const { teams = [], stages = [] } = options;
|
|
22
|
+
const allResults = [];
|
|
23
|
+
for (const [entityType, items] of Object.entries(results)) {
|
|
24
|
+
(items || []).forEach(item => allResults.push({ ...item, _type: entityType }));
|
|
25
|
+
}
|
|
26
|
+
allResults.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
|
27
|
+
|
|
28
|
+
const totalCount = allResults.length;
|
|
29
|
+
const entityCounts = {};
|
|
30
|
+
allResults.forEach(r => { entityCounts[r._type] = (entityCounts[r._type] || 0) + 1; });
|
|
31
|
+
const countBadges = Object.entries(entityCounts).map(([type, count]) => `<span class="badge badge-sm">${type}: ${count}</span>`).join(' ');
|
|
32
|
+
|
|
33
|
+
const resultCards = allResults.length > 0
|
|
34
|
+
? allResults.map(r => resultCard(r, r._type)).join('')
|
|
35
|
+
: '<div class="text-center py-12 text-gray-400">Enter a search query to find engagements, clients, RFIs, and reviews</div>';
|
|
36
|
+
|
|
37
|
+
const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Advanced Search</h1></div>${filterPanel(teams, stages)}<div class="flex items-center gap-2 mb-4"><span class="text-sm text-gray-500">${totalCount} result${totalCount !== 1 ? 's' : ''}</span>${countBadges}</div><div id="search-results">${resultCards}</div>`;
|
|
38
|
+
|
|
39
|
+
const searchScript = `window.doSearch=async function(){const q=document.getElementById('search-query')?.value||'';const entity=document.getElementById('filter-entity')?.value||'';const status=document.getElementById('filter-status')?.value||'';const stage=document.getElementById('filter-stage')?.value||'';const team=document.getElementById('filter-team')?.value||'';const from=document.getElementById('filter-from')?.value||'';const to=document.getElementById('filter-to')?.value||'';const params=new URLSearchParams();if(q)params.set('q',q);if(entity)params.set('entity',entity);if(status)params.set('status',status);if(stage)params.set('stage',stage);if(team)params.set('team',team);if(from)params.set('from',from);if(to)params.set('to',to);window.location='/search?'+params.toString()};document.getElementById('search-query')?.addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})`;
|
|
40
|
+
|
|
41
|
+
return page(user, 'Search | Moonlanding', [{ href: '/', label: 'Dashboard' }, { label: 'Search' }], content, [searchScript]);
|
|
42
|
+
}
|