Fix issues and add linting
This commit is contained in:
17
.env.example
17
.env.example
@@ -3,6 +3,8 @@ API_EDIT_SECRET=change-me-to-a-random-string
|
||||
|
||||
# LLM (required to actually process edits)
|
||||
OLLAMA_API_KEY=
|
||||
# For Ollama Cloud use https://ollama.com, for local Ollama use http://localhost:11434
|
||||
OLLAMA_HOST=https://ollama.com
|
||||
|
||||
# Paths
|
||||
REPO_ROOT=.
|
||||
@@ -11,9 +13,18 @@ IDEMPOTENCY_DB_PATH=./data/dynamic-sites.db
|
||||
# SSR cache
|
||||
SITE_DATA_TTL_MS=500
|
||||
|
||||
# SMS (Telnyx)
|
||||
TELNYX_PUBLIC_KEY=
|
||||
TELNYX_API_KEY=
|
||||
# SMS (Vonage)
|
||||
VONAGE_API_KEY=your_vonage_api_key
|
||||
VONAGE_API_SECRET=your_vonage_api_secret
|
||||
VONAGE_APPLICATION_ID=your_vonage_application_id
|
||||
VONAGE_API_SIGNATURE_SECRET=your_vonage_signature_secret
|
||||
|
||||
# Vonage private key — use ONE of these two options:
|
||||
# Option A: path to the .key file on disk (local / Docker file mount)
|
||||
VONAGE_PRIVATE_KEY_PATH=./private.key
|
||||
# Option B: raw PEM content or base64-encoded PEM (for PaaS / Dokploy env vars)
|
||||
# To base64-encode: base64 -w0 private.key
|
||||
# VONAGE_PRIVATE_KEY=
|
||||
|
||||
# CORS
|
||||
CORS_ALLOWED_ORIGIN=http://localhost:4321
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ data/
|
||||
.env
|
||||
content/.backups/
|
||||
.astro/
|
||||
private.key
|
||||
|
||||
19
README.md
19
README.md
@@ -7,7 +7,7 @@ An LLM-powered website editing framework. Edit your site via SMS, a web API, or
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Channels │
|
||||
│ SMS (Telnyx) │ POST /api/edit │ /editor │
|
||||
│ SMS (Vonage) │ POST /api/edit │ /editor │
|
||||
└───────┬─────────┴────────┬─────────┴─────┬──────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
@@ -46,6 +46,7 @@ An LLM-powered website editing framework. Edit your site via SMS, a web API, or
|
||||
### Prerequisites
|
||||
- Node.js 22+
|
||||
- npm
|
||||
- A Vonage API account with a Messages API-enabled application
|
||||
|
||||
### Local Development
|
||||
|
||||
@@ -69,9 +70,23 @@ Copy `.env.example` to `.env` and set at minimum:
|
||||
|
||||
- `API_EDIT_SECRET` — shared secret for API auth and editor login
|
||||
- `OLLAMA_API_KEY` — required for LLM-powered edits
|
||||
- `VONAGE_API_KEY` — your Vonage API key (from Dashboard)
|
||||
- `VONAGE_API_SECRET` — your Vonage API secret (from Dashboard)
|
||||
- `VONAGE_APPLICATION_ID` — your Vonage application ID
|
||||
- `VONAGE_PRIVATE_KEY_PATH` — path to the `private.key` file generated when creating the Vonage application
|
||||
- `VONAGE_API_SIGNATURE_SECRET` — webhook signature secret (from Dashboard → API Settings)
|
||||
|
||||
See `.env.example` for all options.
|
||||
|
||||
### Vonage Setup
|
||||
|
||||
1. Create a Vonage application in the Dashboard with Messages capability enabled.
|
||||
2. Set the inbound message webhook URL to `https://dynamicsites.kadil.dev/webhooks/inbound` (POST).
|
||||
3. Set the status webhook URL to `https://dynamicsites.kadil.dev/webhooks/status` (POST).
|
||||
4. Under API Settings, ensure Messages API is set as the default for SMS.
|
||||
5. Copy the generated `private.key` to the project root.
|
||||
6. Note your signature secret from Dashboard → API Settings for webhook verification.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
@@ -105,7 +120,7 @@ docker compose up -d
|
||||
│ ├── queue/ # FIFO queue + job processor
|
||||
│ ├── routes/ # API edit, SMS webhook, health
|
||||
│ ├── llm/ # Ollama client with retry/validation
|
||||
│ ├── sms/ # Telnyx parse, reply, templates
|
||||
│ ├── sms/ # Vonage parse, reply, templates
|
||||
│ └── io/ # Filesystem writer (atomic, with backup)
|
||||
├── src/ # Astro SSR site
|
||||
│ ├── pages/
|
||||
|
||||
@@ -38,8 +38,14 @@ services:
|
||||
- API_EDIT_SECRET=${API_EDIT_SECRET:-change-me}
|
||||
- OLLAMA_API_KEY=${OLLAMA_API_KEY:-}
|
||||
- OLLAMA_HOST=${OLLAMA_HOST:-https://ollama.com}
|
||||
- TELNYX_API_KEY=${TELNYX_API_KEY:-}
|
||||
- TELNYX_PUBLIC_KEY=${TELNYX_PUBLIC_KEY:-}
|
||||
- VONAGE_API_KEY=${VONAGE_API_KEY:-}
|
||||
- VONAGE_API_SECRET=${VONAGE_API_SECRET:-}
|
||||
- VONAGE_APPLICATION_ID=${VONAGE_APPLICATION_ID:-}
|
||||
# Use VONAGE_PRIVATE_KEY (base64 or raw PEM) for PaaS/Dokploy deployments,
|
||||
# or VONAGE_PRIVATE_KEY_PATH with a file mount for local/Docker deployments.
|
||||
- VONAGE_PRIVATE_KEY=${VONAGE_PRIVATE_KEY:-}
|
||||
- VONAGE_PRIVATE_KEY_PATH=${VONAGE_PRIVATE_KEY_PATH:-}
|
||||
- VONAGE_API_SIGNATURE_SECRET=${VONAGE_API_SIGNATURE_SECRET:-}
|
||||
- CORS_ALLOWED_ORIGIN=http://localhost:4321
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- PROPOSAL_TTL_MS=${PROPOSAL_TTL_MS:-900000}
|
||||
|
||||
34
eslint.config.js
Normal file
34
eslint.config.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
// Global ignores
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'server/dist/**',
|
||||
'.astro/**',
|
||||
'node_modules/**',
|
||||
'scripts/**',
|
||||
],
|
||||
},
|
||||
|
||||
// Base recommended rules
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
// Project-wide overrides
|
||||
{
|
||||
rules: {
|
||||
// Allow unused vars/args when prefixed with _
|
||||
'@typescript-eslint/no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
}],
|
||||
// Empty catch blocks are intentional in this codebase (fail-through patterns)
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
// We use unknown instead of any; warn when any slips in
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
},
|
||||
);
|
||||
10108
package-lock.json
generated
Normal file
10108
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -14,9 +14,11 @@
|
||||
"dev:server": "npm run dev --workspace=server",
|
||||
"build": "npm run check:content && astro build",
|
||||
"start": "node dist/server/entry.mjs",
|
||||
"check:content": "node scripts/validate-content.js && node scripts/check-canonical.js",
|
||||
"check:content": "npx tsx scripts/validate-content.js && npx tsx scripts/check-canonical.js",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix",
|
||||
"test": "vitest run",
|
||||
"check": "npm run check:content && npm test"
|
||||
"check": "npm run lint && npm run check:content && npm test"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.8.0",
|
||||
@@ -28,9 +30,13 @@
|
||||
"@dynamic-sites/shared": "file:shared"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"eslint": "^9.17.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.8.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dynamic-sites/shared": "file:../shared",
|
||||
"@vonage/jwt": "^1.11.0",
|
||||
"@vonage/messages": "^1.12.0",
|
||||
"@vonage/server-sdk": "^3.14.0",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
@@ -22,6 +25,7 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.2",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
|
||||
@@ -10,28 +10,21 @@ export interface CreateAppDeps {
|
||||
queue: EditQueue;
|
||||
}
|
||||
|
||||
const MAX_BODY = process.env.MAX_UPLOAD_SIZE_BYTES
|
||||
? `${Math.ceil(parseInt(process.env.MAX_UPLOAD_SIZE_BYTES, 10) / 1024)}kb`
|
||||
: '1mb';
|
||||
|
||||
export function createApp(deps: CreateAppDeps): Express {
|
||||
const app = express();
|
||||
|
||||
// Telnyx webhook needs raw body for signature verification — mount BEFORE json parser
|
||||
app.use('/webhooks', express.raw({ type: '*/*' }), (req, _res, next) => {
|
||||
// Parse raw body to JSON for webhook handler
|
||||
if (req.body && Buffer.isBuffer(req.body)) {
|
||||
try {
|
||||
req.body = JSON.parse(req.body.toString());
|
||||
} catch { /* leave as-is */ }
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// JSON parser for everything else
|
||||
app.use(express.json());
|
||||
// JSON parser with size limit
|
||||
app.use(express.json({ limit: MAX_BODY }));
|
||||
|
||||
// CORS for editor cross-origin requests
|
||||
const allowedOrigin = process.env.CORS_ALLOWED_ORIGIN || 'http://localhost:4321';
|
||||
app.use('/api', cors({
|
||||
origin: allowedOrigin,
|
||||
methods: ['GET', 'POST', 'OPTIONS'],
|
||||
methods: ['GET', 'POST', 'PUT', 'OPTIONS'],
|
||||
allowedHeaders: ['Authorization', 'Content-Type'],
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
@@ -34,7 +34,12 @@ export function writeContentFile(
|
||||
const existing = fs.readFileSync(absPath, 'utf-8');
|
||||
beforeHash = fileHash(existing);
|
||||
|
||||
const backupDir = path.join(REPO_ROOT, 'content', '.backups', repoRelativePath);
|
||||
// Derive a clean backup subdirectory name from the repo-relative path.
|
||||
// e.g. "content/sections/hero.json" → "sections/hero" (strip leading content/ and .json)
|
||||
const stripped = repoRelativePath
|
||||
.replace(/^content\//, '')
|
||||
.replace(/\.json$/, '');
|
||||
const backupDir = path.join(REPO_ROOT, 'content', '.backups', stripped);
|
||||
ensureDir(backupDir);
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
fs.copyFileSync(absPath, path.join(backupDir, `${ts}.json`));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
import { routingOutputSchema, type RoutingOutput, classificationSchema, type ClassificationOutput } from '@dynamic-sites/shared';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
const OLLAMA_HOST = process.env.OLLAMA_HOST || 'https://ollama.com';
|
||||
const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://localhost:11434';
|
||||
const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY || '';
|
||||
const PRIMARY_MODEL = process.env.OLLAMA_MODEL || 'qwen3.5:397b-cloud';
|
||||
const FALLBACK_MODEL = process.env.OLLAMA_FALLBACK_MODEL || 'gpt-oss:120b';
|
||||
|
||||
@@ -13,12 +13,30 @@ import { logger } from '../logger.js';
|
||||
|
||||
const REPO_ROOT = process.env.REPO_ROOT || '.';
|
||||
|
||||
/**
|
||||
* In-memory map from job ID → proposal ID.
|
||||
* Used by the HTTP API to let the editor poll for a proposal created by a queued job.
|
||||
* Entries are pruned after 15 minutes to avoid unbounded growth.
|
||||
*/
|
||||
export const jobProposalMap = new Map<string, { proposalId: string; createdAt: number }>();
|
||||
|
||||
const JOB_MAP_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
function pruneJobMap() {
|
||||
const cutoff = Date.now() - JOB_MAP_TTL_MS;
|
||||
for (const [key, entry] of jobProposalMap) {
|
||||
if (entry.createdAt < cutoff) jobProposalMap.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function processEditJob(job: EditJobPayload): Promise<void> {
|
||||
if (job.kind === 'propose') {
|
||||
await handlePropose(job);
|
||||
} else if (job.kind === 'apply') {
|
||||
await handleApply(job);
|
||||
}
|
||||
// Opportunistic cleanup
|
||||
if (jobProposalMap.size > 100) pruneJobMap();
|
||||
}
|
||||
|
||||
async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>) {
|
||||
@@ -120,6 +138,9 @@ async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>)
|
||||
phoneHash: job.smsReplyMeta?.from ? crypto.createHash('sha256').update(job.smsReplyMeta.from).digest('hex').slice(0, 16) : undefined,
|
||||
});
|
||||
|
||||
// Record the job → proposal mapping so the HTTP API can find it
|
||||
jobProposalMap.set(job.id, { proposalId, createdAt: Date.now() });
|
||||
|
||||
log.info({ event: 'proposal.created', proposalId, path: repoRelativePath }, 'Proposal created');
|
||||
|
||||
// Step 6: Notify user
|
||||
@@ -127,18 +148,11 @@ async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>)
|
||||
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.PROPOSAL_SUMMARY(summary, proposalId));
|
||||
}
|
||||
|
||||
// For HTTP callers, the proposal_id is returned via the response (handled in route)
|
||||
// Store on the job for the route handler to read
|
||||
(job as Record<string, unknown>)._proposalId = proposalId;
|
||||
(job as Record<string, unknown>)._summary = summary;
|
||||
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
log.error({ event: 'propose.failed', error: msg }, 'Propose failed');
|
||||
log.error({ event: 'propose.failed', error: (err as Error).message }, 'Propose failed');
|
||||
|
||||
if (job.smsReplyMeta) {
|
||||
const template = msg === 'LLM_UNAVAILABLE' ? SMS_TEMPLATES.LLM_UNAVAILABLE() : SMS_TEMPLATES.LLM_UNAVAILABLE();
|
||||
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, template);
|
||||
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.LLM_UNAVAILABLE());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { Router, type Request, type Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { editRequestSchema, sectionFileSchema } from '@dynamic-sites/shared';
|
||||
import { editRequestSchema, sectionFileSchema, schemaForRepoRelativePath } from '@dynamic-sites/shared';
|
||||
import type { EditQueue } from '../queue/edit-queue.js';
|
||||
import { getProposal, updateProposalStatus } from '../db.js';
|
||||
import { buildSectionManifest } from '../queue/manifest.js';
|
||||
import { writeContentFile } from '../io/write-content.js';
|
||||
import { jobProposalMap } from '../queue/process-edit-job.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
const REPO_ROOT = process.env.REPO_ROOT || '.';
|
||||
@@ -59,6 +61,43 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/section — directly write validated JSON to a section file (editor use)
|
||||
router.put('/section', (req: Request, res: Response) => {
|
||||
const relPath = req.body?.path as string;
|
||||
const data = req.body?.data;
|
||||
|
||||
if (!relPath || data === undefined) {
|
||||
res.status(400).json({ error: 'Missing path or data in request body' });
|
||||
return;
|
||||
}
|
||||
|
||||
const schema = schemaForRepoRelativePath(relPath);
|
||||
if (!schema) {
|
||||
res.status(400).json({ error: `No schema for path: ${relPath}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = schema.safeParse(data);
|
||||
if (!validation.success) {
|
||||
res.status(400).json({ error: 'Validation failed', details: validation.error.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
const absPath = path.join(REPO_ROOT, relPath);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
writeContentFile(relPath, validation.data, { source: 'editor' });
|
||||
res.json({ status: 'written', path: relPath });
|
||||
} catch (err) {
|
||||
logger.error({ event: 'section.write_failed', error: (err as Error).message }, 'Direct section write failed');
|
||||
res.status(500).json({ error: 'Write failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/site-context
|
||||
router.get('/site-context', (_req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -69,7 +108,7 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/edit — propose an edit (NL message)
|
||||
// POST /api/edit — propose an edit (NL message) or confirm/reject a proposal
|
||||
router.post('/edit', async (req: Request, res: Response) => {
|
||||
const parsed = editRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
@@ -100,7 +139,12 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
// Propose flow
|
||||
// Propose flow — message is required
|
||||
if (!message || message.length === 0) {
|
||||
res.status(400).json({ error: 'message is required for edit requests' });
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = crypto.randomUUID();
|
||||
try {
|
||||
deps.queue.enqueue({
|
||||
@@ -137,10 +181,34 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the write-content module (imported dynamically to avoid circular deps)
|
||||
import('../io/write-content.js').then(({ writeContentFile }) => {
|
||||
try {
|
||||
writeContentFile(relPath, parsed.data, { source: 'editor' });
|
||||
res.status(201).json({ status: 'created', path: relPath });
|
||||
} catch (err) {
|
||||
logger.error({ event: 'section.create_failed', error: (err as Error).message }, 'Section creation failed');
|
||||
res.status(500).json({ error: 'Failed to create section' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/job/:id — poll for a proposal created by an async job
|
||||
router.get('/job/:id', (req: Request, res: Response) => {
|
||||
const entry = jobProposalMap.get(req.params.id);
|
||||
if (!entry) {
|
||||
res.json({ status: 'processing' });
|
||||
return;
|
||||
}
|
||||
|
||||
const proposal = getProposal(entry.proposalId);
|
||||
if (!proposal) {
|
||||
res.json({ status: 'processing' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: proposal.status,
|
||||
proposal_id: proposal.proposal_id,
|
||||
summary: proposal.summary_text,
|
||||
repo_relative_path: proposal.repo_relative_path,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,22 +1,57 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { verifySignature } from '@vonage/jwt';
|
||||
import type { EditQueue } from '../queue/edit-queue.js';
|
||||
import { parseTelnyxInboundMessage } from '../sms/parse.js';
|
||||
import { parseVonageInboundMessage } from '../sms/parse.js';
|
||||
import { sendSms } from '../sms/reply.js';
|
||||
import { SMS_TEMPLATES } from '../sms/templates.js';
|
||||
import { isOwnNumber, findAuthorizedSite } from '../sms/config.js';
|
||||
import { claimOnce, checkSmsRateLimit, hashPhone, getPendingProposalByPhone, updateProposalStatus } from '../db.js';
|
||||
import { logger, maskPhone } from '../logger.js';
|
||||
|
||||
const VONAGE_API_SIGNATURE_SECRET = process.env.VONAGE_API_SIGNATURE_SECRET || '';
|
||||
|
||||
export interface WebhookSmsRouterDeps {
|
||||
queue: EditQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Vonage webhook JWT signature (HMAC-SHA256).
|
||||
* Returns true if signature is valid or if no secret is configured (dev mode).
|
||||
*/
|
||||
function verifyVonageWebhook(req: Request): boolean {
|
||||
if (!VONAGE_API_SIGNATURE_SECRET) {
|
||||
logger.warn({ event: 'sms.webhook_no_secret' }, 'No VONAGE_API_SIGNATURE_SECRET configured, skipping verification');
|
||||
return true;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
logger.warn({ event: 'sms.webhook_no_auth' }, 'Missing Authorization header on webhook');
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
try {
|
||||
return verifySignature(token, VONAGE_API_SIGNATURE_SECRET);
|
||||
} catch (err) {
|
||||
logger.warn({ event: 'sms.webhook_verify_failed', error: (err as Error).message }, 'Webhook JWT verification failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createWebhookSmsRouter(deps: WebhookSmsRouterDeps): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/telnyx', (req: Request, res: Response) => {
|
||||
// Respond quickly
|
||||
// Vonage inbound message webhook
|
||||
router.post('/inbound', (req: Request, res: Response) => {
|
||||
// Verify JWT signature
|
||||
if (!verifyVonageWebhook(req)) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Respond quickly — Vonage expects a 200 within a few seconds
|
||||
res.status(200).json({ status: 'received' });
|
||||
|
||||
// Process async
|
||||
@@ -25,11 +60,25 @@ export function createWebhookSmsRouter(deps: WebhookSmsRouterDeps): Router {
|
||||
});
|
||||
});
|
||||
|
||||
// Vonage message status webhook (delivery receipts, etc.)
|
||||
router.post('/status', (req: Request, res: Response) => {
|
||||
if (!verifyVonageWebhook(req)) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const status = (req.body as Record<string, unknown>)?.status;
|
||||
const messageUuid = (req.body as Record<string, unknown>)?.message_uuid;
|
||||
logger.info({ event: 'sms.status_update', messageUuid, status }, 'Message status update');
|
||||
|
||||
res.status(200).json({ status: 'received' });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) {
|
||||
const parsed = parseTelnyxInboundMessage(body);
|
||||
const parsed = parseVonageInboundMessage(body);
|
||||
if (!parsed) {
|
||||
logger.warn({ event: 'sms.parse_failed' }, 'Failed to parse inbound SMS');
|
||||
return;
|
||||
|
||||
@@ -41,7 +41,7 @@ function loadConfig(): SmsSitesConfig | null {
|
||||
return cachedConfig; // Return stale cache if available, otherwise null
|
||||
}
|
||||
|
||||
/** All phone numbers this system sends from (Telnyx numbers). */
|
||||
/** All phone numbers this system sends from (Vonage virtual numbers). */
|
||||
function getOwnNumbers(): string[] {
|
||||
const config = loadConfig();
|
||||
if (!config) return [];
|
||||
@@ -49,8 +49,8 @@ function getOwnNumbers(): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a phone number is one of our own system numbers (the Telnyx numbers we send from).
|
||||
* Used to filter out Telnyx delivery receipts / echo of our own sent messages.
|
||||
* Check if a phone number is one of our own system numbers (the Vonage numbers we send from).
|
||||
* Used to filter out delivery receipts / echo of our own sent messages.
|
||||
*/
|
||||
export function isOwnNumber(phone: string): boolean {
|
||||
return getOwnNumbers().includes(phone);
|
||||
@@ -76,4 +76,4 @@ export function findAuthorizedSite(from: string, to: string): { siteId: string;
|
||||
);
|
||||
if (!site) return null;
|
||||
return { siteId: site.siteId, repoRoot: site.repoRoot || '.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,24 +7,72 @@ export interface ParsedInboundSms {
|
||||
mediaUrls: string[];
|
||||
}
|
||||
|
||||
export function parseTelnyxInboundMessage(body: unknown): ParsedInboundSms | null {
|
||||
/**
|
||||
* Parse an inbound message from the Vonage Messages API webhook.
|
||||
*
|
||||
* Vonage Messages API delivers inbound messages as flat JSON:
|
||||
* {
|
||||
* "message_uuid": "...",
|
||||
* "from": "14155550100",
|
||||
* "to": "14155550200",
|
||||
* "channel": "sms" | "mms",
|
||||
* "message_type": "text" | "image" | "video" | ...,
|
||||
* "text": "Hello",
|
||||
* "image": { "url": "..." },
|
||||
* "timestamp": "..."
|
||||
* }
|
||||
*/
|
||||
export function parseVonageInboundMessage(body: unknown): ParsedInboundSms | null {
|
||||
try {
|
||||
const data = body as Record<string, unknown>;
|
||||
const eventData = (data.data as Record<string, unknown>) || data;
|
||||
const payload = (eventData.payload as Record<string, unknown>) || eventData;
|
||||
|
||||
const from = ((payload.from as Record<string, unknown>)?.phone_number as string) || (payload.from as string) || '';
|
||||
const to = (Array.isArray(payload.to) ? (payload.to[0] as Record<string, unknown>)?.phone_number : (payload.to as Record<string, unknown>)?.phone_number) as string || '';
|
||||
const text = (payload.text as string) || (payload.body as string) || '';
|
||||
const messageId = (payload.id as string) || (eventData.id as string) || '';
|
||||
const from = (data.from as string) || '';
|
||||
const to = (data.to as string) || '';
|
||||
const text = (data.text as string) || '';
|
||||
const messageId = (data.message_uuid as string) || '';
|
||||
const messageType = (data.message_type as string) || '';
|
||||
const channel = (data.channel as string) || 'sms';
|
||||
|
||||
const media = (payload.media as Array<{ url: string }>) || [];
|
||||
const mediaUrls = media.map(m => m.url).filter(Boolean);
|
||||
// Collect media URLs from MMS messages
|
||||
const mediaUrls: string[] = [];
|
||||
if (messageType === 'image' && data.image) {
|
||||
const imageUrl = (data.image as Record<string, unknown>)?.url as string;
|
||||
if (imageUrl) mediaUrls.push(imageUrl);
|
||||
}
|
||||
if (messageType === 'video' && data.video) {
|
||||
const videoUrl = (data.video as Record<string, unknown>)?.url as string;
|
||||
if (videoUrl) mediaUrls.push(videoUrl);
|
||||
}
|
||||
if (messageType === 'file' && data.file) {
|
||||
const fileUrl = (data.file as Record<string, unknown>)?.url as string;
|
||||
if (fileUrl) mediaUrls.push(fileUrl);
|
||||
}
|
||||
|
||||
if (!from || !text) return null;
|
||||
const hasMedia = mediaUrls.length > 0 || channel === 'mms';
|
||||
|
||||
return { messageId, from, to, text: text.trim(), hasMedia: mediaUrls.length > 0, mediaUrls };
|
||||
// For text messages, require at least a from and text
|
||||
if (!from || (!text && !hasMedia)) return null;
|
||||
|
||||
// Normalize phone numbers to E.164 format if they aren't already
|
||||
const normalizedFrom = normalizePhone(from);
|
||||
const normalizedTo = normalizePhone(to);
|
||||
|
||||
return {
|
||||
messageId,
|
||||
from: normalizedFrom,
|
||||
to: normalizedTo,
|
||||
text: text.trim(),
|
||||
hasMedia,
|
||||
mediaUrls,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure phone numbers start with '+' for consistency with config lookups. */
|
||||
function normalizePhone(phone: string): string {
|
||||
if (!phone) return phone;
|
||||
// Vonage sends numbers without '+', so add it for E.164 consistency
|
||||
return phone.startsWith('+') ? phone : `+${phone}`;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,95 @@
|
||||
import { Vonage } from '@vonage/server-sdk';
|
||||
import { SMS } from '@vonage/messages';
|
||||
import { Auth } from '@vonage/auth';
|
||||
import { logger, maskPhone } from '../logger.js';
|
||||
|
||||
const TELNYX_API_KEY = process.env.TELNYX_API_KEY || '';
|
||||
const VONAGE_API_KEY = process.env.VONAGE_API_KEY || '';
|
||||
const VONAGE_API_SECRET = process.env.VONAGE_API_SECRET || '';
|
||||
const VONAGE_APPLICATION_ID = process.env.VONAGE_APPLICATION_ID || '';
|
||||
const VONAGE_PRIVATE_KEY_PATH = process.env.VONAGE_PRIVATE_KEY_PATH || '';
|
||||
const VONAGE_PRIVATE_KEY = process.env.VONAGE_PRIVATE_KEY || '';
|
||||
|
||||
let vonageClient: Vonage | null = null;
|
||||
|
||||
/**
|
||||
* Resolve the private key for the Vonage SDK.
|
||||
*
|
||||
* Supports three modes (checked in order):
|
||||
* 1. VONAGE_PRIVATE_KEY — raw PEM string (starts with "-----BEGIN")
|
||||
* 2. VONAGE_PRIVATE_KEY — base64-encoded PEM (decode first)
|
||||
* 3. VONAGE_PRIVATE_KEY_PATH — path to a .key file on disk
|
||||
*
|
||||
* The Vonage Auth constructor accepts either a file path (string) or
|
||||
* the key content directly (string / Buffer).
|
||||
*/
|
||||
function resolvePrivateKey(): string | undefined {
|
||||
if (VONAGE_PRIVATE_KEY) {
|
||||
// Raw PEM passed directly
|
||||
if (VONAGE_PRIVATE_KEY.startsWith('-----BEGIN')) {
|
||||
return VONAGE_PRIVATE_KEY;
|
||||
}
|
||||
// Base64-encoded PEM (common for PaaS env vars)
|
||||
try {
|
||||
const decoded = Buffer.from(VONAGE_PRIVATE_KEY, 'base64').toString('utf-8');
|
||||
if (decoded.startsWith('-----BEGIN')) return decoded;
|
||||
} catch { /* fall through */ }
|
||||
// Treat as raw content anyway
|
||||
return VONAGE_PRIVATE_KEY;
|
||||
}
|
||||
if (VONAGE_PRIVATE_KEY_PATH) {
|
||||
return VONAGE_PRIVATE_KEY_PATH;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getVonageClient(): Vonage | null {
|
||||
if (vonageClient) return vonageClient;
|
||||
|
||||
const privateKey = resolvePrivateKey();
|
||||
if (!VONAGE_APPLICATION_ID || !privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
vonageClient = new Vonage(new Auth({
|
||||
apiKey: VONAGE_API_KEY,
|
||||
apiSecret: VONAGE_API_SECRET,
|
||||
applicationId: VONAGE_APPLICATION_ID,
|
||||
privateKey,
|
||||
}));
|
||||
|
||||
return vonageClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an SMS reply via the Vonage Messages API.
|
||||
* Phone numbers should be in E.164 format (e.g. +14155550100).
|
||||
* Vonage expects numbers without the leading '+'.
|
||||
*/
|
||||
export async function sendSms(to: string, from: string, body: string): Promise<void> {
|
||||
if (!TELNYX_API_KEY) {
|
||||
logger.warn({ event: 'sms.send_skipped', to: maskPhone(to) }, 'No TELNYX_API_KEY, skipping SMS send');
|
||||
const client = getVonageClient();
|
||||
|
||||
if (!client) {
|
||||
logger.warn({ event: 'sms.send_skipped', to: maskPhone(to) }, 'No Vonage credentials configured, skipping SMS send');
|
||||
logger.info({ event: 'sms.would_send', body }, 'SMS body (dev mode)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('https://api.telnyx.com/v2/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TELNYX_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ from, to, text: body }),
|
||||
});
|
||||
// Strip leading '+' — Vonage expects bare numbers
|
||||
const toNumber = to.replace(/^\+/, '');
|
||||
const fromNumber = from.replace(/^\+/, '');
|
||||
|
||||
if (!resp.ok) {
|
||||
logger.error({ event: 'sms.send_failed', status: resp.status }, 'Failed to send SMS');
|
||||
} else {
|
||||
logger.info({ event: 'sms.sent', to: maskPhone(to) }, 'SMS sent');
|
||||
}
|
||||
try {
|
||||
await client.messages.send(
|
||||
new SMS({ to: toNumber, from: fromNumber, text: body })
|
||||
);
|
||||
|
||||
logger.info({ event: 'sms.sent', to: maskPhone(to) }, 'SMS sent');
|
||||
} catch (err) {
|
||||
logger.error({ event: 'sms.send_error', error: (err as Error).message }, 'SMS send error');
|
||||
const error = err as Error & { response?: { data?: unknown; status?: number } };
|
||||
logger.error({
|
||||
event: 'sms.send_error',
|
||||
error: error.message,
|
||||
status: error.response?.status,
|
||||
}, 'SMS send error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const SMS_TEMPLATES = {
|
||||
PROPOSAL_SUMMARY: (summary: string, proposalId: string) =>
|
||||
PROPOSAL_SUMMARY: (summary: string, _proposalId: string) =>
|
||||
`Proposed change: ${summary}\n\nReply YES to apply or NO to cancel.`,
|
||||
|
||||
APPLIED: (summary: string) =>
|
||||
|
||||
@@ -109,8 +109,10 @@ export const smsSitesConfigSchema = z.object({
|
||||
export type SmsSitesConfig = z.infer<typeof smsSitesConfigSchema>;
|
||||
|
||||
// ── Edit Request ──
|
||||
// message is required for propose requests but optional for confirm requests.
|
||||
// The handler enforces that message is non-empty when confirm is absent.
|
||||
export const editRequestSchema = z.object({
|
||||
message: z.string().min(1),
|
||||
message: z.string().default(''),
|
||||
repo_relative_path: z.string().optional(),
|
||||
proposal_id: z.string().optional(),
|
||||
confirm: z.enum(['yes', 'no']).optional(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
interface ManifestEntry {
|
||||
id: string;
|
||||
@@ -28,28 +28,39 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
const [proposalId, setProposalId] = useState<string | null>(null);
|
||||
const [proposalSummary, setProposalSummary] = useState<string>('');
|
||||
|
||||
const headers = {
|
||||
// Keep a ref to the polling interval so we can clean it up
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const headers = useRef({
|
||||
'Authorization': `Bearer ${apiSecret}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
});
|
||||
|
||||
// Clean up polling interval on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchManifest = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers });
|
||||
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers: headers.current });
|
||||
const data = await res.json();
|
||||
setSections(data.sections || []);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setStatus('Failed to load sections');
|
||||
}
|
||||
}, [orchestratorUrl, apiSecret]);
|
||||
}, [orchestratorUrl]);
|
||||
|
||||
useEffect(() => { fetchManifest(); }, [fetchManifest]);
|
||||
|
||||
const loadSection = async (entry: ManifestEntry) => {
|
||||
setSelectedSection(entry);
|
||||
setView('edit');
|
||||
setStatus('');
|
||||
try {
|
||||
const res = await fetch(`${orchestratorUrl}/api/section?path=${encodeURIComponent(entry.repo_relative_path)}`, { headers });
|
||||
const res = await fetch(`${orchestratorUrl}/api/section?path=${encodeURIComponent(entry.repo_relative_path)}`, { headers: headers.current });
|
||||
const data = await res.json();
|
||||
setSectionJson(JSON.stringify(data, null, 2));
|
||||
} catch {
|
||||
@@ -60,21 +71,32 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
const saveSection = async () => {
|
||||
if (!selectedSection) return;
|
||||
setLoading(true);
|
||||
setStatus('Saving...');
|
||||
setStatus('Validating & saving...');
|
||||
try {
|
||||
const parsed = JSON.parse(sectionJson);
|
||||
const res = await fetch(`${orchestratorUrl}/api/edit`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
|
||||
// Write the JSON directly via PUT /api/section
|
||||
const res = await fetch(`${orchestratorUrl}/api/section`, {
|
||||
method: 'PUT',
|
||||
headers: headers.current,
|
||||
body: JSON.stringify({
|
||||
message: `Direct JSON update to ${selectedSection.repo_relative_path}`,
|
||||
repo_relative_path: selectedSection.repo_relative_path,
|
||||
path: selectedSection.repo_relative_path,
|
||||
data: parsed,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setStatus(data.status === 'processing' ? 'Edit submitted — processing...' : `Status: ${data.status}`);
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setStatus('Saved successfully.');
|
||||
fetchManifest();
|
||||
} else {
|
||||
const details = result.details
|
||||
? result.details.map((d: { path: string[]; message: string }) => `${d.path.join('.')}: ${d.message}`).join(', ')
|
||||
: result.error;
|
||||
setStatus(`Save failed: ${details}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Save failed');
|
||||
setStatus(`Invalid JSON: ${(err as Error).message}`);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -85,16 +107,22 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
setStatus('Sending to AI...');
|
||||
setProposalId(null);
|
||||
setProposalSummary('');
|
||||
|
||||
// Clear any existing poll
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${orchestratorUrl}/api/edit`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: headers.current,
|
||||
body: JSON.stringify({ message: nlMessage }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.job_id) {
|
||||
setStatus('Processing... checking for proposal.');
|
||||
// Poll for proposal
|
||||
setStatus('Processing... waiting for AI proposal.');
|
||||
pollForProposal(data.job_id);
|
||||
} else {
|
||||
setStatus(`Response: ${JSON.stringify(data)}`);
|
||||
@@ -105,22 +133,36 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const pollForProposal = async (jobId: string) => {
|
||||
// Simple poll: check recent proposals. In production, use SSE or websockets.
|
||||
const pollForProposal = (jobId: string) => {
|
||||
let attempts = 0;
|
||||
const interval = setInterval(async () => {
|
||||
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
attempts++;
|
||||
if (attempts > 30) {
|
||||
clearInterval(interval);
|
||||
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
setStatus('Timed out waiting for proposal. The AI may still be processing.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Re-fetch manifest to see if anything changed, or check proposal endpoint
|
||||
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers });
|
||||
const res = await fetch(`${orchestratorUrl}/api/job/${jobId}`, { headers: headers.current });
|
||||
const data = await res.json();
|
||||
setSections(data.sections || []);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (data.status === 'pending' && data.proposal_id) {
|
||||
// Proposal is ready — show confirm UI
|
||||
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
setProposalId(data.proposal_id);
|
||||
setProposalSummary(data.summary || 'Change proposed.');
|
||||
setStatus('');
|
||||
} else if (data.status === 'applied' || data.status === 'rejected' || data.status === 'expired') {
|
||||
// Already resolved
|
||||
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
setStatus(`Proposal was ${data.status}.`);
|
||||
}
|
||||
// status === 'processing' → keep polling
|
||||
} catch { /* ignore network errors during polling */ }
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
@@ -130,13 +172,14 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
try {
|
||||
const res = await fetch(`${orchestratorUrl}/api/edit`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ message: '', confirm, proposal_id: proposalId }),
|
||||
headers: headers.current,
|
||||
body: JSON.stringify({ confirm, proposal_id: proposalId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
await res.json();
|
||||
setStatus(confirm === 'yes' ? 'Applied! Refreshing...' : 'Cancelled.');
|
||||
setProposalId(null);
|
||||
setProposalSummary('');
|
||||
setNlMessage('');
|
||||
if (confirm === 'yes') {
|
||||
setTimeout(() => { fetchManifest(); }, 1000);
|
||||
}
|
||||
@@ -232,8 +275,10 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
onChange={e => setSectionJson(e.target.value)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
|
||||
<button style={styles.btn} onClick={saveSection} disabled={loading}>Save</button>
|
||||
<button style={styles.btnOutline} onClick={() => { setView('sections'); setSelectedSection(null); }}>Back</button>
|
||||
<button style={styles.btn} onClick={saveSection} disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button style={styles.btnOutline} onClick={() => { setView('sections'); setSelectedSection(null); setStatus(''); }}>Back</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -243,15 +288,15 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
<div>
|
||||
<h3 style={styles.subhead}>Describe Your Edit</h3>
|
||||
<p style={{ fontSize: '0.9rem', color: '#6b6b6b', marginBottom: '1rem' }}>
|
||||
Tell the AI what you want to change. For example: "Update the hero headline to say Grand Opening This Weekend"
|
||||
or "Hide the promo banner" or "Add a new event on May 15th".
|
||||
Tell the AI what you want to change. For example: “Update the hero headline to say Grand Opening This Weekend”
|
||||
or “Hide the promo banner” or “Add a new event on May 15th”.
|
||||
</p>
|
||||
<input
|
||||
style={styles.input}
|
||||
placeholder="Describe what you want to change..."
|
||||
value={nlMessage}
|
||||
onChange={e => setNlMessage(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && submitNlEdit()}
|
||||
onKeyDown={e => e.key === 'Enter' && !loading && submitNlEdit()}
|
||||
/>
|
||||
<button style={styles.btn} onClick={submitNlEdit} disabled={loading || !nlMessage.trim()}>
|
||||
{loading ? 'Processing...' : 'Submit Edit'}
|
||||
@@ -262,8 +307,8 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
<strong>Proposed change:</strong>
|
||||
<p style={{ margin: '0.5rem 0' }}>{proposalSummary}</p>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button style={styles.btn} onClick={() => confirmProposal('yes')}>Yes, Apply</button>
|
||||
<button style={styles.btnDanger} onClick={() => confirmProposal('no')}>No, Cancel</button>
|
||||
<button style={styles.btn} onClick={() => confirmProposal('yes')} disabled={loading}>Yes, Apply</button>
|
||||
<button style={styles.btnDanger} onClick={() => confirmProposal('no')} disabled={loading}>No, Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -271,7 +316,7 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
)}
|
||||
|
||||
{/* ── Create Section ── */}
|
||||
{view === 'create' && <CreateSection orchestratorUrl={orchestratorUrl} headers={headers} onCreated={() => { setView('sections'); fetchManifest(); }} />}
|
||||
{view === 'create' && <CreateSection orchestratorUrl={orchestratorUrl} headers={headers.current} onCreated={() => { setView('sections'); fetchManifest(); }} />}
|
||||
|
||||
{status && <p style={styles.status}>{status}</p>}
|
||||
</div>
|
||||
|
||||
@@ -3,37 +3,63 @@ import path from 'node:path';
|
||||
import { parseSiteBundle, type SiteBundle } from './site-bundle.ts';
|
||||
|
||||
const REPO_ROOT = process.env.REPO_ROOT || '.';
|
||||
const TTL = parseInt(process.env.SITE_DATA_TTL_MS || '2000', 10);
|
||||
const TTL = parseInt(process.env.SITE_DATA_TTL_MS || '500', 10);
|
||||
|
||||
let cached: { data: SiteBundle; loadedAt: number } | null = null;
|
||||
|
||||
/** Fallback bundle used when content files are missing or unreadable. */
|
||||
function fallbackBundle(): SiteBundle {
|
||||
return {
|
||||
siteContext: {
|
||||
businessName: 'Site',
|
||||
tone: 'professional and friendly',
|
||||
},
|
||||
sections: [],
|
||||
events: { events: [] },
|
||||
};
|
||||
}
|
||||
|
||||
export function loadSiteData(): SiteBundle {
|
||||
const now = Date.now();
|
||||
if (cached && now - cached.loadedAt < TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const siteContextRaw = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'site-context.json'), 'utf-8')
|
||||
);
|
||||
try {
|
||||
const siteContextRaw = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'site-context.json'), 'utf-8')
|
||||
);
|
||||
|
||||
const eventsRaw = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'content/events.json'), 'utf-8')
|
||||
);
|
||||
|
||||
const sectionsDir = path.join(REPO_ROOT, 'content/sections');
|
||||
const sectionRaws: unknown[] = [];
|
||||
if (fs.existsSync(sectionsDir)) {
|
||||
for (const file of fs.readdirSync(sectionsDir).filter(f => f.endsWith('.json'))) {
|
||||
let eventsRaw: unknown = { events: [] };
|
||||
const eventsPath = path.join(REPO_ROOT, 'content/events.json');
|
||||
if (fs.existsSync(eventsPath)) {
|
||||
try {
|
||||
sectionRaws.push(JSON.parse(fs.readFileSync(path.join(sectionsDir, file), 'utf-8')));
|
||||
eventsRaw = JSON.parse(fs.readFileSync(eventsPath, 'utf-8'));
|
||||
} catch {
|
||||
// skip invalid files
|
||||
// Use empty events if file is corrupt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = parseSiteBundle(siteContextRaw, eventsRaw, sectionRaws);
|
||||
cached = { data, loadedAt: now };
|
||||
return data;
|
||||
const sectionsDir = path.join(REPO_ROOT, 'content/sections');
|
||||
const sectionRaws: unknown[] = [];
|
||||
if (fs.existsSync(sectionsDir)) {
|
||||
for (const file of fs.readdirSync(sectionsDir).filter(f => f.endsWith('.json'))) {
|
||||
try {
|
||||
sectionRaws.push(JSON.parse(fs.readFileSync(path.join(sectionsDir, file), 'utf-8')));
|
||||
} catch {
|
||||
// skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = parseSiteBundle(siteContextRaw, eventsRaw, sectionRaws);
|
||||
cached = { data, loadedAt: now };
|
||||
return data;
|
||||
} catch {
|
||||
// If site-context.json is missing or corrupt, return a minimal fallback
|
||||
// so the SSR server doesn't crash on every request.
|
||||
const fb = fallbackBundle();
|
||||
cached = { data: fb, loadedAt: now };
|
||||
return fb;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user