thatcher 1.0.34 → 1.0.35
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/package.json +2 -1
- package/src/adapters/google-gmail.js +114 -0
- package/src/app/api/cron/trigger/route.js +15 -28
- package/src/app/api/email/receive/route.js +9 -1
- package/src/app/api/email/send/route.js +10 -2
- package/src/app/api/files/[id]/route.js +1 -1
- package/src/app/api/health/route.js +6 -5
- package/src/index.js +8 -2
- package/src/lib/auth-route-helpers.js +1 -1
- package/src/lib/events-engine.js +1 -1
- package/src/lib/export-sink.js +2 -1
- package/src/lib/route-resolver.js +8 -0
- package/src/lib/validation/csrf.js +1 -1
- package/src/lib/validation/rate-limit.js +1 -1
- package/src/services/notification-engine.js +83 -0
- package/src/ui/file-dialogs.js +9 -2
- package/src/ui/review-detail-panels.js +1 -1
- package/src/ui/styles2.css +3 -7
- package/src/lib/generic-crud-handler.js +0 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thatcher",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.35",
|
|
4
4
|
"description": "A config-driven application framework for building data-intensive web apps without code.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"lucia": "^3.2.2",
|
|
59
59
|
"nodemailer": "^9.0.1",
|
|
60
60
|
"webjsx": "^0.0.73",
|
|
61
|
+
"ws": "^8.13.0",
|
|
61
62
|
"xstate": "^5.0.0"
|
|
62
63
|
},
|
|
63
64
|
"devDependencies": {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { createLogger } from '../lib/logger.js';
|
|
3
|
+
import { getJWTClient, getOAuth2Client } from './google-auth.js';
|
|
4
|
+
import { buildConfig } from '../config/env.js';
|
|
5
|
+
|
|
6
|
+
const log = createLogger('[GoogleGmail]');
|
|
7
|
+
|
|
8
|
+
export function getGmailClient(user = null) {
|
|
9
|
+
let client;
|
|
10
|
+
|
|
11
|
+
if (user?.oauth_token) {
|
|
12
|
+
// User-delegated access
|
|
13
|
+
client = getOAuth2Client();
|
|
14
|
+
client.setCredentials({ access_token: user.oauth_token });
|
|
15
|
+
} else {
|
|
16
|
+
// Service account (app-wide)
|
|
17
|
+
client = getJWTClient();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!client) throw new Error('Google Gmail not configured');
|
|
21
|
+
|
|
22
|
+
return google.gmail({ version: 'v1', auth: client });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function encodeHeader(value) {
|
|
26
|
+
// RFC 2047 encoded-word for any non-ASCII header value (subject/name).
|
|
27
|
+
if (/^[\x00-\x7F]*$/.test(value)) return value;
|
|
28
|
+
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildRawMessage({ to, from, subject, body, html, cc, bcc, attachments = [], inReplyTo, references }) {
|
|
32
|
+
const boundary = `boundary_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
33
|
+
const headers = [
|
|
34
|
+
`To: ${to}`,
|
|
35
|
+
`From: ${from}`,
|
|
36
|
+
`Subject: ${encodeHeader(subject || '')}`,
|
|
37
|
+
];
|
|
38
|
+
if (cc) headers.push(`Cc: ${cc}`);
|
|
39
|
+
if (bcc) headers.push(`Bcc: ${bcc}`);
|
|
40
|
+
if (inReplyTo) headers.push(`In-Reply-To: ${inReplyTo}`);
|
|
41
|
+
if (references) headers.push(`References: ${references}`);
|
|
42
|
+
headers.push('MIME-Version: 1.0');
|
|
43
|
+
|
|
44
|
+
const hasAttachments = Array.isArray(attachments) && attachments.length > 0;
|
|
45
|
+
|
|
46
|
+
let message;
|
|
47
|
+
if (!hasAttachments) {
|
|
48
|
+
if (html) {
|
|
49
|
+
headers.push('Content-Type: text/html; charset="UTF-8"');
|
|
50
|
+
message = `${headers.join('\r\n')}\r\n\r\n${html}`;
|
|
51
|
+
} else {
|
|
52
|
+
headers.push('Content-Type: text/plain; charset="UTF-8"');
|
|
53
|
+
message = `${headers.join('\r\n')}\r\n\r\n${body || ''}`;
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
headers.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
|
57
|
+
const parts = [];
|
|
58
|
+
|
|
59
|
+
parts.push(
|
|
60
|
+
`--${boundary}`,
|
|
61
|
+
html ? 'Content-Type: text/html; charset="UTF-8"' : 'Content-Type: text/plain; charset="UTF-8"',
|
|
62
|
+
'',
|
|
63
|
+
html || body || '',
|
|
64
|
+
''
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
for (const att of attachments) {
|
|
68
|
+
const content = Buffer.isBuffer(att.content) ? att.content : Buffer.from(att.content || '', att.encoding || 'base64');
|
|
69
|
+
parts.push(
|
|
70
|
+
`--${boundary}`,
|
|
71
|
+
`Content-Type: ${att.contentType || att.mimeType || 'application/octet-stream'}; name="${att.filename || att.name || 'attachment'}"`,
|
|
72
|
+
'Content-Transfer-Encoding: base64',
|
|
73
|
+
`Content-Disposition: attachment; filename="${att.filename || att.name || 'attachment'}"`,
|
|
74
|
+
'',
|
|
75
|
+
content.toString('base64'),
|
|
76
|
+
''
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
parts.push(`--${boundary}--`);
|
|
81
|
+
message = `${headers.join('\r\n')}\r\n\r\n${parts.join('\r\n')}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return Buffer.from(message)
|
|
85
|
+
.toString('base64')
|
|
86
|
+
.replace(/\+/g, '-')
|
|
87
|
+
.replace(/\//g, '_')
|
|
88
|
+
.replace(/=+$/, '');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function sendEmail(options, user = null) {
|
|
92
|
+
const gmail = getGmailClient(user);
|
|
93
|
+
const from = options.from || buildConfig().email.from;
|
|
94
|
+
|
|
95
|
+
const raw = buildRawMessage({ ...options, from });
|
|
96
|
+
|
|
97
|
+
const requestBody = { raw };
|
|
98
|
+
if (options.inReplyTo || options.references) {
|
|
99
|
+
// threadId is optional; Gmail threads by References/In-Reply-To headers
|
|
100
|
+
// already embedded in the raw message.
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const res = await gmail.users.messages.send({
|
|
105
|
+
userId: 'me',
|
|
106
|
+
requestBody,
|
|
107
|
+
});
|
|
108
|
+
log.info('sent:', { messageId: res.data.id });
|
|
109
|
+
return { id: res.data.id, messageId: res.data.id, threadId: res.data.threadId };
|
|
110
|
+
} catch (err) {
|
|
111
|
+
log.error('send failed:', { message: err.message });
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -1,51 +1,38 @@
|
|
|
1
1
|
import { createLogger } from '@/lib/logger.js';
|
|
2
|
-
import {
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
3
|
|
|
4
4
|
const log = createLogger('[Cron]');
|
|
5
|
-
import { create } from '@/engine';
|
|
6
5
|
|
|
7
6
|
export const runtime = 'nodejs';
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
const
|
|
8
|
+
function isValidCronSecret(token) {
|
|
9
|
+
const secret = process.env.CRON_SECRET;
|
|
10
|
+
const tokenBuf = Buffer.from(token || '', 'utf8');
|
|
11
|
+
const secretBuf = Buffer.from(secret || '', 'utf8');
|
|
12
|
+
return !!secret && !!token && tokenBuf.length === secretBuf.length && timingSafeEqual(tokenBuf, secretBuf);
|
|
13
|
+
}
|
|
11
14
|
|
|
15
|
+
export async function POST(request) {
|
|
12
16
|
try {
|
|
13
17
|
const authHeader = request.headers.get('authorization');
|
|
14
18
|
const token = authHeader?.replace('Bearer ', '');
|
|
15
19
|
|
|
16
|
-
if (!token
|
|
20
|
+
if (!isValidCronSecret(token)) {
|
|
17
21
|
return new Response(
|
|
18
22
|
JSON.stringify({ status: 'error', message: 'Unauthorized' }),
|
|
19
23
|
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
|
20
24
|
);
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
await create('job_execution_log', {
|
|
27
|
-
timestamp: Math.floor(Date.now() / 1000),
|
|
28
|
-
total_jobs: results.total || 0,
|
|
29
|
-
executed_jobs: results.executed || 0,
|
|
30
|
-
failed_jobs: results.failed || 0,
|
|
31
|
-
duration_ms: duration,
|
|
32
|
-
status: results.failed > 0 ? 'partial_failure' : 'success',
|
|
33
|
-
error_details: results.errors || null
|
|
34
|
-
}).catch(err => log.error('log error:', { message: err.message }));
|
|
35
|
-
|
|
27
|
+
// job-engine was removed (the module never existed in this repo); this
|
|
28
|
+
// endpoint is not wired to a real job runner, so return a clean 501
|
|
29
|
+
// instead of crashing on a dead import.
|
|
36
30
|
return new Response(
|
|
37
|
-
JSON.stringify({
|
|
38
|
-
|
|
39
|
-
timestamp: new Date().toISOString(),
|
|
40
|
-
total_jobs: results.total,
|
|
41
|
-
executed_jobs: results.executed,
|
|
42
|
-
failed_jobs: results.failed,
|
|
43
|
-
duration_ms: duration,
|
|
44
|
-
details: results.details || []
|
|
45
|
-
}),
|
|
46
|
-
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
31
|
+
JSON.stringify({ status: 'error', message: 'cron trigger not implemented' }),
|
|
32
|
+
{ status: 501, headers: { 'Content-Type': 'application/json' } }
|
|
47
33
|
);
|
|
48
34
|
} catch (error) {
|
|
35
|
+
log.error('cron trigger error:', { message: error.message });
|
|
49
36
|
return new Response(
|
|
50
37
|
JSON.stringify({ status: 'error', message: error.message, timestamp: new Date().toISOString() }),
|
|
51
38
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
@@ -2,11 +2,19 @@ import { NextResponse } from '@/lib/next-polyfills';
|
|
|
2
2
|
import { createLogger } from '@/lib/logger.js';
|
|
3
3
|
import { genId, now } from '@/lib/id-helpers';
|
|
4
4
|
import { create } from '@/engine';
|
|
5
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
5
6
|
|
|
6
7
|
const log = createLogger('[EmailReceive]');
|
|
7
8
|
import path from 'path';
|
|
8
9
|
import fs from 'fs';
|
|
9
10
|
|
|
11
|
+
function isValidWebhookSecret(token) {
|
|
12
|
+
const secret = process.env.EMAIL_WEBHOOK_SECRET;
|
|
13
|
+
const tokenBuf = Buffer.from(token || '', 'utf8');
|
|
14
|
+
const secretBuf = Buffer.from(secret || '', 'utf8');
|
|
15
|
+
return !!secret && !!token && tokenBuf.length === secretBuf.length && timingSafeEqual(tokenBuf, secretBuf);
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
const EMAIL_ATTACHMENTS_DIR = path.resolve(process.cwd(), 'data', 'temp_email_attachments');
|
|
11
19
|
|
|
12
20
|
if (!fs.existsSync(EMAIL_ATTACHMENTS_DIR)) {
|
|
@@ -17,7 +25,7 @@ export async function POST(request) {
|
|
|
17
25
|
try {
|
|
18
26
|
const authHeader = request.headers.get('authorization');
|
|
19
27
|
const token = authHeader?.replace('Bearer ', '');
|
|
20
|
-
if (!token
|
|
28
|
+
if (!isValidWebhookSecret(token)) {
|
|
21
29
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
22
30
|
}
|
|
23
31
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from '@/lib/next-polyfills';
|
|
2
2
|
import { createLogger } from '@/lib/logger.js';
|
|
3
3
|
import { now } from '@/lib/id-helpers';
|
|
4
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
4
5
|
|
|
5
6
|
const log = createLogger('[Email]');
|
|
6
7
|
import { list, update } from '@/engine';
|
|
@@ -8,6 +9,13 @@ import { getConfigEngine } from '@/lib/config-generator-engine';
|
|
|
8
9
|
import { EMAIL_STATUS } from '@/config/constants';
|
|
9
10
|
import { sendSingleEmail, checkFailureRate } from '@/lib/email-sender';
|
|
10
11
|
|
|
12
|
+
function isValidCronSecret(token) {
|
|
13
|
+
const secret = process.env.CRON_SECRET;
|
|
14
|
+
const tokenBuf = Buffer.from(token || '', 'utf8');
|
|
15
|
+
const secretBuf = Buffer.from(secret || '', 'utf8');
|
|
16
|
+
return !!secret && !!token && tokenBuf.length === secretBuf.length && timingSafeEqual(tokenBuf, secretBuf);
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
let emailConfig = null;
|
|
12
20
|
|
|
13
21
|
async function getEmailConfig() {
|
|
@@ -20,7 +28,7 @@ async function getEmailConfig() {
|
|
|
20
28
|
|
|
21
29
|
export async function POST(request) {
|
|
22
30
|
const token = request.headers.get('authorization')?.replace('Bearer ', '');
|
|
23
|
-
if (!token
|
|
31
|
+
if (!isValidCronSecret(token))
|
|
24
32
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
25
33
|
|
|
26
34
|
try {
|
|
@@ -61,7 +69,7 @@ export async function POST(request) {
|
|
|
61
69
|
|
|
62
70
|
export async function GET(request) {
|
|
63
71
|
const token = request.headers.get('authorization')?.replace('Bearer ', '');
|
|
64
|
-
if (!token
|
|
72
|
+
if (!isValidCronSecret(token))
|
|
65
73
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
66
74
|
|
|
67
75
|
try {
|
|
@@ -20,7 +20,7 @@ export async function GET(request, { params }) {
|
|
|
20
20
|
return NextResponse.json({ error: 'Permission denied' }, { status: HTTP.FORBIDDEN });
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
const fileRecord = get('file', id);
|
|
23
|
+
const fileRecord = await get('file', id);
|
|
24
24
|
if (!fileRecord) {
|
|
25
25
|
return notFound('File not found');
|
|
26
26
|
}
|
|
@@ -25,23 +25,24 @@ export const GET = async (request) => {
|
|
|
25
25
|
const start = process.hrtime.bigint()
|
|
26
26
|
|
|
27
27
|
// busybase liveness probe (replaces the SQLite SELECT 1 + wal_checkpoint).
|
|
28
|
-
|
|
28
|
+
let dbConnected = true
|
|
29
|
+
try { await count('user', {}); } catch (e) { dbConnected = false; log.warn('db probe failed', { message: e?.message }) }
|
|
29
30
|
|
|
30
31
|
const dbLatency = Number(process.hrtime.bigint() - start) / 1000000
|
|
31
32
|
const url = new URL(request.url)
|
|
32
33
|
const detailed = url.searchParams.get('detailed') === 'true'
|
|
33
34
|
|
|
34
35
|
const health = {
|
|
35
|
-
status: 'ok',
|
|
36
|
+
status: dbConnected ? 'ok' : 'degraded',
|
|
36
37
|
timestamp: new Date().toISOString(),
|
|
37
38
|
uptime: process.uptime(),
|
|
38
39
|
uptime_ms: Math.round(process.uptime() * 1000),
|
|
39
40
|
last_sync_at: getLastSyncAt(),
|
|
40
41
|
database: {
|
|
41
|
-
connected:
|
|
42
|
+
connected: dbConnected,
|
|
42
43
|
latency: dbLatency
|
|
43
44
|
},
|
|
44
|
-
db: 'ok'
|
|
45
|
+
db: dbConnected ? 'ok' : 'error'
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
if (detailed) {
|
|
@@ -65,7 +66,7 @@ export const GET = async (request) => {
|
|
|
65
66
|
return new Response(
|
|
66
67
|
JSON.stringify(health, null, 2),
|
|
67
68
|
{
|
|
68
|
-
status: 200,
|
|
69
|
+
status: dbConnected ? 200 : 503,
|
|
69
70
|
headers: { 'Content-Type': 'application/json' }
|
|
70
71
|
}
|
|
71
72
|
)
|
package/src/index.js
CHANGED
|
@@ -332,13 +332,19 @@ export class Thatcher {
|
|
|
332
332
|
|
|
333
333
|
// === Database transaction ===
|
|
334
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Non-atomic passthrough: busybase has no transaction primitive, so this simply
|
|
337
|
+
* invokes the callback with NO rollback guarantee on partial failure. Callers
|
|
338
|
+
* must not rely on withTransaction() for atomicity; check supportsTransactions
|
|
339
|
+
* to detect this at runtime.
|
|
340
|
+
*/
|
|
335
341
|
async withTransaction(callback) {
|
|
336
|
-
// busybase has no transaction primitive; the old query-engine's withTransaction
|
|
337
|
-
// was a pass-through, so preserve that contract rather than import a dead file.
|
|
338
342
|
return callback();
|
|
339
343
|
}
|
|
340
344
|
}
|
|
341
345
|
|
|
346
|
+
Thatcher.prototype.supportsTransactions = false;
|
|
347
|
+
|
|
342
348
|
export function createThatcher(options) {
|
|
343
349
|
return new Thatcher(options);
|
|
344
350
|
}
|
package/src/lib/events-engine.js
CHANGED
|
@@ -3,7 +3,7 @@ import { hookEngine } from '@/lib/hook-engine.js';
|
|
|
3
3
|
|
|
4
4
|
const log = createLogger('[EventsEngine]');
|
|
5
5
|
import { list, get, update, create, remove } from '@/engine.js';
|
|
6
|
-
import { queueEmail } from '@/
|
|
6
|
+
import { queueEmail } from '@/services/notification-engine.js';
|
|
7
7
|
import { safeJsonParse } from '@/lib/safe-json.js';
|
|
8
8
|
import { validateTransition } from '@/lib/workflow-engine.js';
|
|
9
9
|
import { AppError } from '@/lib/error-handler';
|
package/src/lib/export-sink.js
CHANGED
|
@@ -21,11 +21,12 @@ export class ExportSink {
|
|
|
21
21
|
this._exportCount = 0;
|
|
22
22
|
this._errorCount = 0;
|
|
23
23
|
this._lastError = null;
|
|
24
|
+
this._options = options;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
async init() {
|
|
27
28
|
if (this.target === 'file') {
|
|
28
|
-
this._filePath =
|
|
29
|
+
this._filePath = this._options.filePath || process.env.OBSERVABILITY_EXPORT_FILE || path.join(process.cwd(), 'observability.jsonl');
|
|
29
30
|
logger.info('File export sink initialized', { path: this._filePath });
|
|
30
31
|
} else if (this.target === 'http') {
|
|
31
32
|
if (!this.url) {
|
|
@@ -83,6 +83,14 @@ const DOMAINS = ['friday', 'mwr'];
|
|
|
83
83
|
|
|
84
84
|
export function resolveRoute(__dirname, pathname, url) {
|
|
85
85
|
const pathParts = pathname.slice(5).split('/').filter(Boolean);
|
|
86
|
+
|
|
87
|
+
// Reject any '..' or '.' segment before it can reach a filesystem path
|
|
88
|
+
// join or a dynamic import -- otherwise a crafted URL can traverse out of
|
|
89
|
+
// src/app/api into arbitrary files on disk.
|
|
90
|
+
if (pathParts.some(seg => seg === '..' || seg === '.')) {
|
|
91
|
+
return { routeFile: null, params: {}, isDomain: false, firstPart: pathParts[0], pathParts };
|
|
92
|
+
}
|
|
93
|
+
|
|
86
94
|
const firstPart = pathParts[0];
|
|
87
95
|
const isDomain = DOMAINS.includes(firstPart);
|
|
88
96
|
let routeFile = null;
|
|
@@ -80,6 +80,89 @@ export async function getUnreadCount(userId) {
|
|
|
80
80
|
})).length;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Resolve a `recipients` token (a role/group name used across events-engine.js
|
|
84
|
+
// call sites, e.g. 'client_users', 'team_members', 'collaborator') to a list of
|
|
85
|
+
// user records to notify. There is no dedicated recipient-resolution engine in
|
|
86
|
+
// this codebase yet, so this composes the existing busybase-store queries the
|
|
87
|
+
// same way the rest of the app looks up users -- a single explicit user object
|
|
88
|
+
// on the context (context.recipientUser / a matching *_id field) is honored
|
|
89
|
+
// directly, and a bare role/group token that cannot be resolved to concrete
|
|
90
|
+
// users is logged and skipped rather than silently guessed at.
|
|
91
|
+
async function resolveRecipients(recipients, context) {
|
|
92
|
+
const { list, get } = await import('../lib/busybase-store.js');
|
|
93
|
+
|
|
94
|
+
if (context.recipientUser) return [context.recipientUser];
|
|
95
|
+
if (context.collaborator?.email) return [context.collaborator];
|
|
96
|
+
|
|
97
|
+
if (recipients === 'client_users' && context.engagement?.client_id) {
|
|
98
|
+
return list('user', { client_id: context.engagement.client_id });
|
|
99
|
+
}
|
|
100
|
+
if ((recipients === 'team_members' || recipients === 'team_partners') && context.engagement?.team_id) {
|
|
101
|
+
const team = await get('team', context.engagement.team_id);
|
|
102
|
+
const { safeJsonParse } = await import('../lib/safe-json.js');
|
|
103
|
+
const userIds = safeJsonParse(team?.users, []);
|
|
104
|
+
const users = [];
|
|
105
|
+
for (const id of userIds) {
|
|
106
|
+
const u = await get('user', id);
|
|
107
|
+
if (u) users.push(u);
|
|
108
|
+
}
|
|
109
|
+
return recipients === 'team_partners' ? users.filter(u => u.role === 'partner') : users;
|
|
110
|
+
}
|
|
111
|
+
if (recipients === 'client_admin' && context.engagement?.client_id) {
|
|
112
|
+
return list('user', { client_id: context.engagement.client_id, role: 'admin' });
|
|
113
|
+
}
|
|
114
|
+
if (recipients === 'assigned_users' && (context.rfi?.assigned_to || context.rfi?.assigned_users)) {
|
|
115
|
+
const ids = Array.isArray(context.rfi.assigned_users) ? context.rfi.assigned_users : [context.rfi.assigned_to].filter(Boolean);
|
|
116
|
+
const users = [];
|
|
117
|
+
for (const id of ids) {
|
|
118
|
+
const u = await get('user', id);
|
|
119
|
+
if (u) users.push(u);
|
|
120
|
+
}
|
|
121
|
+
return users;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
log.warn(`queueEmail: unresolved recipients token "${recipients}"`);
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function templateNameForType(type) {
|
|
129
|
+
// events-engine.js passes descriptive template names (e.g.
|
|
130
|
+
// 'engagement_info_gathering'); the built-in getTemplates() set in
|
|
131
|
+
// email-sender.js is generic ('notification', 'invitation'), so an
|
|
132
|
+
// unrecognized specific name falls back to the generic notification shape.
|
|
133
|
+
return 'notification';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Send a templated notification email to a resolved set of recipients.
|
|
138
|
+
* Composes the existing sendTemplatedEmail (src/services/email-sender.js)
|
|
139
|
+
* rather than reimplementing delivery -- this is the queueEmail primitive
|
|
140
|
+
* events-engine.js drives its afterCreate/afterUpdate hooks through.
|
|
141
|
+
*/
|
|
142
|
+
export async function queueEmail(templateName, context = {}) {
|
|
143
|
+
const { recipients, ...rest } = context;
|
|
144
|
+
try {
|
|
145
|
+
const users = await resolveRecipients(recipients, rest);
|
|
146
|
+
if (!users.length) return { sent: 0, templateName };
|
|
147
|
+
|
|
148
|
+
const { sendTemplatedEmail } = await import('./email-sender.js');
|
|
149
|
+
let sent = 0;
|
|
150
|
+
for (const user of users) {
|
|
151
|
+
if (!user?.email) continue;
|
|
152
|
+
try {
|
|
153
|
+
await sendTemplatedEmail(templateNameForType(templateName), user.email, { ...rest, templateName });
|
|
154
|
+
sent++;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
log.error('queueEmail send failed:', { message: err.message, templateName, to: user.email });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { sent, templateName };
|
|
160
|
+
} catch (err) {
|
|
161
|
+
log.error('queueEmail failed:', { message: err.message, templateName });
|
|
162
|
+
return { sent: 0, templateName, error: err.message };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
83
166
|
function interpolate(template, context) {
|
|
84
167
|
if (!template) return '';
|
|
85
168
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
package/src/ui/file-dialogs.js
CHANGED
|
@@ -48,7 +48,8 @@ export function quickViewAttachment() {
|
|
|
48
48
|
<div class="dialog-footer"><a id="qv-download" href="#" download class="btn btn-primary btn-sm">Download</a><button class="btn btn-ghost btn-sm" data-dialog-close="quick-view">Close</button></div>
|
|
49
49
|
</div></div>
|
|
50
50
|
<script>
|
|
51
|
-
|
|
51
|
+
${FD_ESC}
|
|
52
|
+
window.quickView=function(url,name,type){document.getElementById('quick-view').style.display='flex';document.getElementById('quick-view-title').textContent=name||'Preview';document.getElementById('qv-download').href=url;var c=document.getElementById('qv-content');if(type&&type.startsWith('image/')){c.innerHTML='<img src="'+url+'" alt="'+fdEsc(name||'File preview')+'" style="max-width:100%;max-height:70vh"/>'}else if(type==='application/pdf'){c.innerHTML='<iframe src="'+url+'" style="width:100%;height:70vh;border:none"></iframe>'}else{c.innerHTML='<div class="py-8 text-gray-500"><div style="display:flex;justify-content:center"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg></div><div class="mt-2">'+fdEsc(name)+'</div><div class="text-xs mt-1">Preview not available</div></div>'}};
|
|
52
53
|
</script>`;
|
|
53
54
|
}
|
|
54
55
|
export function fetchCachedPdf(fileId) {
|
|
@@ -67,9 +68,15 @@ export function fetchCachedPdf(fileId) {
|
|
|
67
68
|
};
|
|
68
69
|
</script>`;
|
|
69
70
|
}
|
|
71
|
+
function fdEscServer(s) {
|
|
72
|
+
return String(s == null ? '' : s)
|
|
73
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
74
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
75
|
+
}
|
|
76
|
+
|
|
70
77
|
export function fileAttachmentBar(files = []) {
|
|
71
78
|
if (!files.length) return '';
|
|
72
|
-
const items = files.map(f => `<div class="flex items-center gap-2 p-1 rounded hover:bg-gray-100 cursor-pointer" data-action="quickView" data-args='["/api/file/${f.id}/download","${(f.name || '')
|
|
79
|
+
const items = files.map(f => `<div class="flex items-center gap-2 p-1 rounded hover:bg-gray-100 cursor-pointer" data-action="quickView" data-args='["/api/file/${fdEscServer(f.id)}/download","${fdEscServer(f.name || '')}","${fdEscServer(f.mime_type || '')}"]'><span style="display:inline-flex"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span><span class="text-xs truncate" style="max-width:120px">${fdEscServer(f.name || 'file')}</span></div>`).join('');
|
|
73
80
|
return `<div class="flex flex-wrap gap-1 mt-2">${items}</div>`;
|
|
74
81
|
}
|
|
75
82
|
export function fileLinksBar(links = []) {
|
|
@@ -14,7 +14,7 @@ export function highlightRow(h) {
|
|
|
14
14
|
try { tags = JSON.parse(h.tags || '[]') || []; } catch {}
|
|
15
15
|
const isFlagged = flags.includes('flagged');
|
|
16
16
|
|
|
17
|
-
const flagBtn = `<button data-action="toggleFlag" data-args='["${esc(h.id)}","${isFlagged}"]' title="${isFlagged ? 'Unflag' : 'Flag'}" style="background:none;border:none;cursor:pointer;padding:2px 4px;font-size:15px;line-height:1;color:${isFlagged ? 'var(--color-warning)' : 'var(--color-text-muted)'}">${isFlagged ? '
|
|
17
|
+
const flagBtn = `<button data-action="toggleFlag" data-args='["${esc(h.id)}","${isFlagged}"]' title="${isFlagged ? 'Unflag' : 'Flag'}" style="background:none;border:none;cursor:pointer;padding:2px 4px;font-size:15px;line-height:1;color:${isFlagged ? 'var(--color-warning)' : 'var(--color-text-muted)'}">${isFlagged ? 'Unflag' : 'Flag'}</button>`;
|
|
18
18
|
const tagPills = tags.map(t => `<span style="display:inline-flex;align-items:center;gap:3px;background:var(--color-info-bg,#eff6ff);color:var(--color-info,#1e40af);font-size:11px;padding:2px 8px;border-radius:9999px;font-weight:500">${esc(t)}<button data-action="removeTag" data-args='["${esc(h.id)}","${esc(t)}"]' style="background:none;border:none;cursor:pointer;padding:0;margin-left:2px;font-size:11px;color:var(--color-text-muted);line-height:1">×</button></span>`).join(' ');
|
|
19
19
|
const addTagBtn = `<button data-action="openAddTag" data-args='["${esc(h.id)}"]' style="background:none;border:none;cursor:pointer;font-size:11px;color:var(--color-text-muted);padding:1px 5px;border:1px dashed var(--color-border,#e5e7eb);border-radius:9999px">+tag</button>`;
|
|
20
20
|
const tagsCell = `<div style="display:flex;flex-wrap:wrap;gap:3px;align-items:center;min-width:80px">${tagPills}${addTagBtn}</div>`;
|
package/src/ui/styles2.css
CHANGED
|
@@ -880,11 +880,9 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
|
|
|
880
880
|
.review-row-name { font-weight: 500; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
881
881
|
@media (max-width: 767px) { .review-row-name { max-width: 140px; } }
|
|
882
882
|
|
|
883
|
-
/*
|
|
884
|
-
TAILWIND-COMPATIBLE UTILITY CLASSES
|
|
883
|
+
/* ---- TAILWIND-COMPATIBLE UTILITY CLASSES ----
|
|
885
884
|
Required because rippleui.css is PurgeCSS-built without
|
|
886
|
-
general utilities. These cover all classes used in UI files.
|
|
887
|
-
═══════════════════════════════════════════════════════════ */
|
|
885
|
+
general utilities. These cover all classes used in UI files. */
|
|
888
886
|
|
|
889
887
|
/* Display */
|
|
890
888
|
.flex { display: flex; }
|
|
@@ -1336,9 +1334,7 @@ tr.is-read td { color: rgb(var(--base-content, 30 30 35) / 0.7); }
|
|
|
1336
1334
|
.italic { font-style: italic; }
|
|
1337
1335
|
.not-italic { font-style: normal; }
|
|
1338
1336
|
|
|
1339
|
-
/*
|
|
1340
|
-
VISUAL POLISH & IMPROVED COMPONENT STYLES
|
|
1341
|
-
═══════════════════════════════════════════════════════════ */
|
|
1337
|
+
/* ---- VISUAL POLISH & IMPROVED COMPONENT STYLES ---- */
|
|
1342
1338
|
|
|
1343
1339
|
/* Nav separator hidden */
|
|
1344
1340
|
.nav-sep { display: none; }
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { createCrudHandlers } from '../lib/crud-factory.js';
|
|
2
|
-
import { getConfigEngineSync } from '../lib/config-generator-engine.js';
|
|
3
|
-
|
|
4
|
-
export function getEntityHandlers(entityName) {
|
|
5
|
-
return createCrudHandlers(entityName);
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function hasEntity(entityName) {
|
|
9
|
-
try {
|
|
10
|
-
const engine = getConfigEngineSync();
|
|
11
|
-
engine.generateEntitySpec(entityName);
|
|
12
|
-
return true;
|
|
13
|
-
} catch {
|
|
14
|
-
return false;
|
|
15
|
-
}
|
|
16
|
-
}
|