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.
Files changed (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/package.json +73 -0
  4. package/src/adapters/google-auth.js +148 -0
  5. package/src/adapters/google-drive.js +209 -0
  6. package/src/app/api/[entity]/[[...path]]/route.js +33 -0
  7. package/src/app/api/audit/dashboard/route.js +77 -0
  8. package/src/app/api/audit/logs/route.js +46 -0
  9. package/src/app/api/audit/permissions/[id]/route.js +37 -0
  10. package/src/app/api/audit/permissions/route.js +93 -0
  11. package/src/app/api/audit/permissions/stats/route.js +29 -0
  12. package/src/app/api/audit/route.js +79 -0
  13. package/src/app/api/audit/stats/route.js +18 -0
  14. package/src/app/api/auth/google/callback/route.js +94 -0
  15. package/src/app/api/auth/google/route.js +58 -0
  16. package/src/app/api/auth/login/route.js +121 -0
  17. package/src/app/api/auth/logout/route.js +52 -0
  18. package/src/app/api/auth/me/route.js +16 -0
  19. package/src/app/api/auth/mwr-bridge/route.js +77 -0
  20. package/src/app/api/auth/password-reset/route.js +65 -0
  21. package/src/app/api/cron/trigger/route.js +58 -0
  22. package/src/app/api/csrf-token/route.js +8 -0
  23. package/src/app/api/debug/config/route.js +22 -0
  24. package/src/app/api/debug/hooks/route.js +16 -0
  25. package/src/app/api/debug/plugins/route.js +20 -0
  26. package/src/app/api/debug/sqlite/route.js +21 -0
  27. package/src/app/api/debug/sync/route.js +29 -0
  28. package/src/app/api/debug/workflow/route.js +20 -0
  29. package/src/app/api/domains/[domain]/route.js +26 -0
  30. package/src/app/api/domains/route.js +21 -0
  31. package/src/app/api/email/allocate/batch/route.js +106 -0
  32. package/src/app/api/email/allocate/route.js +141 -0
  33. package/src/app/api/email/receive/route.js +158 -0
  34. package/src/app/api/email/route.js +3 -0
  35. package/src/app/api/email/send/route.js +77 -0
  36. package/src/app/api/email/unallocated/route.js +47 -0
  37. package/src/app/api/files/[id]/route.js +38 -0
  38. package/src/app/api/health/route.js +95 -0
  39. package/src/app/api/metrics/route.js +73 -0
  40. package/src/app/api/monitoring/dashboard/route.js +18 -0
  41. package/src/cli.js +243 -0
  42. package/src/config/config-loader.js +112 -0
  43. package/src/config/constants.js +127 -0
  44. package/src/config/env.js +164 -0
  45. package/src/config/spec-helpers.js +232 -0
  46. package/src/engine.server.js +212 -0
  47. package/src/index.js +368 -0
  48. package/src/lib/accessibility.js +162 -0
  49. package/src/lib/action-factory.js +34 -0
  50. package/src/lib/action-utils.js +21 -0
  51. package/src/lib/alert-manager.js +189 -0
  52. package/src/lib/api-error-wrapper.js +125 -0
  53. package/src/lib/api-helpers.js +53 -0
  54. package/src/lib/api.js +82 -0
  55. package/src/lib/audit-logger-enhanced.js +117 -0
  56. package/src/lib/audit-logger.js +193 -0
  57. package/src/lib/auth-middleware.js +102 -0
  58. package/src/lib/auth-route-helpers.js +83 -0
  59. package/src/lib/business-rules-engine.js +86 -0
  60. package/src/lib/compression.js +44 -0
  61. package/src/lib/config-field-helpers.js +91 -0
  62. package/src/lib/config-generator-engine.js +445 -0
  63. package/src/lib/config-helpers.js +120 -0
  64. package/src/lib/connection-guard.js +79 -0
  65. package/src/lib/crud-action-helpers.js +34 -0
  66. package/src/lib/crud-factory.js +83 -0
  67. package/src/lib/crud-handlers.js +244 -0
  68. package/src/lib/csrf-protection.js +63 -0
  69. package/src/lib/database-core.js +258 -0
  70. package/src/lib/database-migrations.js +96 -0
  71. package/src/lib/date-utils.js +159 -0
  72. package/src/lib/db-backup.js +97 -0
  73. package/src/lib/db-monitor.js +127 -0
  74. package/src/lib/domain-loader.js +82 -0
  75. package/src/lib/email-sender.js +100 -0
  76. package/src/lib/error-boundary.js +134 -0
  77. package/src/lib/error-handler.js +84 -0
  78. package/src/lib/error-recovery.js +190 -0
  79. package/src/lib/error-resilience.js +130 -0
  80. package/src/lib/errors.js +69 -0
  81. package/src/lib/events-engine.js +182 -0
  82. package/src/lib/field-iterator.js +50 -0
  83. package/src/lib/field-registry.js +68 -0
  84. package/src/lib/field-types.js +154 -0
  85. package/src/lib/generic-crud-handler.js +32 -0
  86. package/src/lib/health-monitor.js +134 -0
  87. package/src/lib/hook-engine.js +169 -0
  88. package/src/lib/hot-reload/cache-invalidator.js +115 -0
  89. package/src/lib/hot-reload/checkpoint.js +95 -0
  90. package/src/lib/hot-reload/debug-exposure.js +67 -0
  91. package/src/lib/hot-reload/directory-watcher.js +96 -0
  92. package/src/lib/hot-reload/index.js +50 -0
  93. package/src/lib/hot-reload/mutex.js +75 -0
  94. package/src/lib/hot-reload/promise-container.js +66 -0
  95. package/src/lib/hot-reload/route-wrapper.js +46 -0
  96. package/src/lib/hot-reload/safe-error.js +51 -0
  97. package/src/lib/hot-reload/supervisor.js +161 -0
  98. package/src/lib/hot-reload/timeout-wrapper.js +52 -0
  99. package/src/lib/http-methods-factory.js +25 -0
  100. package/src/lib/index-optimizer.js +96 -0
  101. package/src/lib/index.js +35 -0
  102. package/src/lib/list-data-transform.js +39 -0
  103. package/src/lib/log-aggregator.js +116 -0
  104. package/src/lib/logger.js +55 -0
  105. package/src/lib/metrics-collector.js +102 -0
  106. package/src/lib/minifier.js +19 -0
  107. package/src/lib/monitoring-init.js +67 -0
  108. package/src/lib/next-compat.js +80 -0
  109. package/src/lib/next-polyfills.js +135 -0
  110. package/src/lib/perf-monitor.js +91 -0
  111. package/src/lib/progress-components.js +181 -0
  112. package/src/lib/query-cache.js +126 -0
  113. package/src/lib/query-engine-write.js +221 -0
  114. package/src/lib/query-engine.js +399 -0
  115. package/src/lib/query-perf.js +117 -0
  116. package/src/lib/query-string-adapter.js +75 -0
  117. package/src/lib/realtime-server.js +67 -0
  118. package/src/lib/render-cache.js +61 -0
  119. package/src/lib/request-tracker.js +43 -0
  120. package/src/lib/resource-hints.js +29 -0
  121. package/src/lib/resource-monitor.js +117 -0
  122. package/src/lib/response-formatter.js +80 -0
  123. package/src/lib/route-helpers.js +33 -0
  124. package/src/lib/route-resolver.js +142 -0
  125. package/src/lib/safe-json.js +8 -0
  126. package/src/lib/server-bootstrap.js +71 -0
  127. package/src/lib/stage-pipeline.js +153 -0
  128. package/src/lib/state-protocol.js +171 -0
  129. package/src/lib/state-transport-client.js +169 -0
  130. package/src/lib/state-transport-reconnect.js +121 -0
  131. package/src/lib/state-transport-server.js +181 -0
  132. package/src/lib/static-server.js +97 -0
  133. package/src/lib/status-helpers.js +98 -0
  134. package/src/lib/universal-handler.js +7 -0
  135. package/src/lib/utils.js +93 -0
  136. package/src/lib/validate.js +197 -0
  137. package/src/lib/validation/business-validators.js +61 -0
  138. package/src/lib/validation/csrf.js +51 -0
  139. package/src/lib/validation/file-validators.js +34 -0
  140. package/src/lib/validation/format-validators.js +106 -0
  141. package/src/lib/validation/index.js +19 -0
  142. package/src/lib/validation/rate-limit.js +31 -0
  143. package/src/lib/validation/security-validators.js +78 -0
  144. package/src/lib/validation-middleware.js +133 -0
  145. package/src/lib/validators.js +105 -0
  146. package/src/lib/with-audit-logging.js +63 -0
  147. package/src/lib/with-error-handler.js +31 -0
  148. package/src/lib/workflow-engine.js +250 -0
  149. package/src/server/server.js +305 -0
  150. package/src/services/collaborator-role.service.js +205 -0
  151. package/src/services/email-sender.js +105 -0
  152. package/src/services/notification-engine.js +110 -0
  153. package/src/services/permission.service.js +181 -0
  154. package/src/ui/advanced-search-renderer.js +42 -0
  155. package/src/ui/advanced-widgets.js +47 -0
  156. package/src/ui/auth-pages.js +114 -0
  157. package/src/ui/auth-styles.js +53 -0
  158. package/src/ui/client.js +99 -0
  159. package/src/ui/collaboration-dialogs.js +31 -0
  160. package/src/ui/common-handlers.js +156 -0
  161. package/src/ui/component-engine.js +103 -0
  162. package/src/ui/dashboard-renderer.js +150 -0
  163. package/src/ui/dialog-engine.js +147 -0
  164. package/src/ui/dialog-factory.js +38 -0
  165. package/src/ui/engagement-cards.js +76 -0
  166. package/src/ui/engagement-dialogs.js +100 -0
  167. package/src/ui/engagement-grid-renderer.js +109 -0
  168. package/src/ui/entity-renderer.js +176 -0
  169. package/src/ui/event-delegation.js +108 -0
  170. package/src/ui/fetch-json.js +24 -0
  171. package/src/ui/file-dialogs.js +81 -0
  172. package/src/ui/flexup-report-renderer.js +173 -0
  173. package/src/ui/format-helpers.js +102 -0
  174. package/src/ui/global-tags.js +144 -0
  175. package/src/ui/highlight-threading-renderer.js +176 -0
  176. package/src/ui/idle-logout.js +156 -0
  177. package/src/ui/job-management-renderer.js +61 -0
  178. package/src/ui/layout.js +219 -0
  179. package/src/ui/letter-dialogs.js +37 -0
  180. package/src/ui/ml-console-renderer.js +108 -0
  181. package/src/ui/monitoring-dashboard-client.js +136 -0
  182. package/src/ui/monitoring-dashboard.js +134 -0
  183. package/src/ui/notifications-renderer.js +51 -0
  184. package/src/ui/page-handler-admin.js +121 -0
  185. package/src/ui/page-handler-helpers.js +111 -0
  186. package/src/ui/page-handler-reviews.js +165 -0
  187. package/src/ui/page-handler-rfi.js +42 -0
  188. package/src/ui/page-handler.js +210 -0
  189. package/src/ui/password-reset-page.js +146 -0
  190. package/src/ui/perf-helpers.js +96 -0
  191. package/src/ui/perf-renderer.js +71 -0
  192. package/src/ui/permissions-ui.js +163 -0
  193. package/src/ui/picker-dialogs.js +100 -0
  194. package/src/ui/render-helpers.js +124 -0
  195. package/src/ui/renderer.js +35 -0
  196. package/src/ui/review-comparison-renderer.js +58 -0
  197. package/src/ui/review-detail-panels.js +71 -0
  198. package/src/ui/review-detail-renderer.js +170 -0
  199. package/src/ui/review-detail-script.js +95 -0
  200. package/src/ui/review-mwr-renderer.js +113 -0
  201. package/src/ui/review-renderer.js +202 -0
  202. package/src/ui/review-widgets.js +88 -0
  203. package/src/ui/review-zone-nav.js +12 -0
  204. package/src/ui/rfi-detail-renderer.js +191 -0
  205. package/src/ui/rfi-renderer.js +194 -0
  206. package/src/ui/rfi-report-renderer.js +56 -0
  207. package/src/ui/rippleui.css +1 -0
  208. package/src/ui/settings-renderer-advanced.js +195 -0
  209. package/src/ui/settings-renderer-advanced2.js +158 -0
  210. package/src/ui/settings-renderer-teams.js +112 -0
  211. package/src/ui/settings-renderer.js +166 -0
  212. package/src/ui/spacing-system.js +155 -0
  213. package/src/ui/standalone-login.js +109 -0
  214. package/src/ui/styles.css +2530 -0
  215. package/src/ui/styles2.css +1602 -0
  216. package/src/ui/test-page.js +23 -0
  217. package/src/ui/validation-rules.js +73 -0
  218. package/src/ui/validation-ui.js +147 -0
  219. package/src/ui/virtual-scroll.js +107 -0
  220. package/src/ui/webjsx.js +61 -0
  221. 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
+ }