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)
|
# LLM (required to actually process edits)
|
||||||
OLLAMA_API_KEY=
|
OLLAMA_API_KEY=
|
||||||
|
# For Ollama Cloud use https://ollama.com, for local Ollama use http://localhost:11434
|
||||||
|
OLLAMA_HOST=https://ollama.com
|
||||||
|
|
||||||
# Paths
|
# Paths
|
||||||
REPO_ROOT=.
|
REPO_ROOT=.
|
||||||
@@ -11,9 +13,18 @@ IDEMPOTENCY_DB_PATH=./data/dynamic-sites.db
|
|||||||
# SSR cache
|
# SSR cache
|
||||||
SITE_DATA_TTL_MS=500
|
SITE_DATA_TTL_MS=500
|
||||||
|
|
||||||
# SMS (Telnyx)
|
# SMS (Vonage)
|
||||||
TELNYX_PUBLIC_KEY=
|
VONAGE_API_KEY=your_vonage_api_key
|
||||||
TELNYX_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
|
||||||
CORS_ALLOWED_ORIGIN=http://localhost:4321
|
CORS_ALLOWED_ORIGIN=http://localhost:4321
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ data/
|
|||||||
.env
|
.env
|
||||||
content/.backups/
|
content/.backups/
|
||||||
.astro/
|
.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 │
|
│ 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
|
### Prerequisites
|
||||||
- Node.js 22+
|
- Node.js 22+
|
||||||
- npm
|
- npm
|
||||||
|
- A Vonage API account with a Messages API-enabled application
|
||||||
|
|
||||||
### Local Development
|
### 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
|
- `API_EDIT_SECRET` — shared secret for API auth and editor login
|
||||||
- `OLLAMA_API_KEY` — required for LLM-powered edits
|
- `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.
|
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
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -105,7 +120,7 @@ docker compose up -d
|
|||||||
│ ├── queue/ # FIFO queue + job processor
|
│ ├── queue/ # FIFO queue + job processor
|
||||||
│ ├── routes/ # API edit, SMS webhook, health
|
│ ├── routes/ # API edit, SMS webhook, health
|
||||||
│ ├── llm/ # Ollama client with retry/validation
|
│ ├── llm/ # Ollama client with retry/validation
|
||||||
│ ├── sms/ # Telnyx parse, reply, templates
|
│ ├── sms/ # Vonage parse, reply, templates
|
||||||
│ └── io/ # Filesystem writer (atomic, with backup)
|
│ └── io/ # Filesystem writer (atomic, with backup)
|
||||||
├── src/ # Astro SSR site
|
├── src/ # Astro SSR site
|
||||||
│ ├── pages/
|
│ ├── pages/
|
||||||
|
|||||||
@@ -38,8 +38,14 @@ services:
|
|||||||
- API_EDIT_SECRET=${API_EDIT_SECRET:-change-me}
|
- API_EDIT_SECRET=${API_EDIT_SECRET:-change-me}
|
||||||
- OLLAMA_API_KEY=${OLLAMA_API_KEY:-}
|
- OLLAMA_API_KEY=${OLLAMA_API_KEY:-}
|
||||||
- OLLAMA_HOST=${OLLAMA_HOST:-https://ollama.com}
|
- OLLAMA_HOST=${OLLAMA_HOST:-https://ollama.com}
|
||||||
- TELNYX_API_KEY=${TELNYX_API_KEY:-}
|
- VONAGE_API_KEY=${VONAGE_API_KEY:-}
|
||||||
- TELNYX_PUBLIC_KEY=${TELNYX_PUBLIC_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
|
- CORS_ALLOWED_ORIGIN=http://localhost:4321
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
- PROPOSAL_TTL_MS=${PROPOSAL_TTL_MS:-900000}
|
- 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",
|
"dev:server": "npm run dev --workspace=server",
|
||||||
"build": "npm run check:content && astro build",
|
"build": "npm run check:content && astro build",
|
||||||
"start": "node dist/server/entry.mjs",
|
"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",
|
"test": "vitest run",
|
||||||
"check": "npm run check:content && npm test"
|
"check": "npm run lint && npm run check:content && npm test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5.8.0",
|
"astro": "^5.8.0",
|
||||||
@@ -28,9 +30,13 @@
|
|||||||
"@dynamic-sites/shared": "file:shared"
|
"@dynamic-sites/shared": "file:shared"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
|
"typescript-eslint": "^8.20.0",
|
||||||
"vitest": "^3.1.0"
|
"vitest": "^3.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dynamic-sites/shared": "file:../shared",
|
"@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",
|
"better-sqlite3": "^11.8.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
@@ -22,6 +25,7 @@
|
|||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.2",
|
"@types/express": "^5.0.2",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.8.0"
|
"typescript": "^5.8.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,28 +10,21 @@ export interface CreateAppDeps {
|
|||||||
queue: EditQueue;
|
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 {
|
export function createApp(deps: CreateAppDeps): Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Telnyx webhook needs raw body for signature verification — mount BEFORE json parser
|
// JSON parser with size limit
|
||||||
app.use('/webhooks', express.raw({ type: '*/*' }), (req, _res, next) => {
|
app.use(express.json({ limit: MAX_BODY }));
|
||||||
// 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());
|
|
||||||
|
|
||||||
// CORS for editor cross-origin requests
|
// CORS for editor cross-origin requests
|
||||||
const allowedOrigin = process.env.CORS_ALLOWED_ORIGIN || 'http://localhost:4321';
|
const allowedOrigin = process.env.CORS_ALLOWED_ORIGIN || 'http://localhost:4321';
|
||||||
app.use('/api', cors({
|
app.use('/api', cors({
|
||||||
origin: allowedOrigin,
|
origin: allowedOrigin,
|
||||||
methods: ['GET', 'POST', 'OPTIONS'],
|
methods: ['GET', 'POST', 'PUT', 'OPTIONS'],
|
||||||
allowedHeaders: ['Authorization', 'Content-Type'],
|
allowedHeaders: ['Authorization', 'Content-Type'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ export function writeContentFile(
|
|||||||
const existing = fs.readFileSync(absPath, 'utf-8');
|
const existing = fs.readFileSync(absPath, 'utf-8');
|
||||||
beforeHash = fileHash(existing);
|
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);
|
ensureDir(backupDir);
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
fs.copyFileSync(absPath, path.join(backupDir, `${ts}.json`));
|
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 { routingOutputSchema, type RoutingOutput, classificationSchema, type ClassificationOutput } from '@dynamic-sites/shared';
|
||||||
import { logger } from '../logger.js';
|
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 OLLAMA_API_KEY = process.env.OLLAMA_API_KEY || '';
|
||||||
const PRIMARY_MODEL = process.env.OLLAMA_MODEL || 'qwen3.5:397b-cloud';
|
const PRIMARY_MODEL = process.env.OLLAMA_MODEL || 'qwen3.5:397b-cloud';
|
||||||
const FALLBACK_MODEL = process.env.OLLAMA_FALLBACK_MODEL || 'gpt-oss:120b';
|
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 || '.';
|
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> {
|
export async function processEditJob(job: EditJobPayload): Promise<void> {
|
||||||
if (job.kind === 'propose') {
|
if (job.kind === 'propose') {
|
||||||
await handlePropose(job);
|
await handlePropose(job);
|
||||||
} else if (job.kind === 'apply') {
|
} else if (job.kind === 'apply') {
|
||||||
await handleApply(job);
|
await handleApply(job);
|
||||||
}
|
}
|
||||||
|
// Opportunistic cleanup
|
||||||
|
if (jobProposalMap.size > 100) pruneJobMap();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>) {
|
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,
|
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');
|
log.info({ event: 'proposal.created', proposalId, path: repoRelativePath }, 'Proposal created');
|
||||||
|
|
||||||
// Step 6: Notify user
|
// 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));
|
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) {
|
} catch (err) {
|
||||||
const msg = (err as Error).message;
|
log.error({ event: 'propose.failed', error: (err as Error).message }, 'Propose failed');
|
||||||
log.error({ event: 'propose.failed', error: msg }, 'Propose failed');
|
|
||||||
|
|
||||||
if (job.smsReplyMeta) {
|
if (job.smsReplyMeta) {
|
||||||
const template = msg === 'LLM_UNAVAILABLE' ? SMS_TEMPLATES.LLM_UNAVAILABLE() : SMS_TEMPLATES.LLM_UNAVAILABLE();
|
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.LLM_UNAVAILABLE());
|
||||||
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, template);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { Router, type Request, type Response } from 'express';
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
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 type { EditQueue } from '../queue/edit-queue.js';
|
||||||
import { getProposal, updateProposalStatus } from '../db.js';
|
import { getProposal, updateProposalStatus } from '../db.js';
|
||||||
import { buildSectionManifest } from '../queue/manifest.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';
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
const REPO_ROOT = process.env.REPO_ROOT || '.';
|
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
|
// GET /api/site-context
|
||||||
router.get('/site-context', (_req: Request, res: Response) => {
|
router.get('/site-context', (_req: Request, res: Response) => {
|
||||||
try {
|
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) => {
|
router.post('/edit', async (req: Request, res: Response) => {
|
||||||
const parsed = editRequestSchema.safeParse(req.body);
|
const parsed = editRequestSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -100,7 +139,12 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
|
|||||||
return;
|
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();
|
const jobId = crypto.randomUUID();
|
||||||
try {
|
try {
|
||||||
deps.queue.enqueue({
|
deps.queue.enqueue({
|
||||||
@@ -137,10 +181,34 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the write-content module (imported dynamically to avoid circular deps)
|
try {
|
||||||
import('../io/write-content.js').then(({ writeContentFile }) => {
|
|
||||||
writeContentFile(relPath, parsed.data, { source: 'editor' });
|
writeContentFile(relPath, parsed.data, { source: 'editor' });
|
||||||
res.status(201).json({ status: 'created', path: relPath });
|
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 { Router, type Request, type Response } from 'express';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { verifySignature } from '@vonage/jwt';
|
||||||
import type { EditQueue } from '../queue/edit-queue.js';
|
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 { sendSms } from '../sms/reply.js';
|
||||||
import { SMS_TEMPLATES } from '../sms/templates.js';
|
import { SMS_TEMPLATES } from '../sms/templates.js';
|
||||||
import { isOwnNumber, findAuthorizedSite } from '../sms/config.js';
|
import { isOwnNumber, findAuthorizedSite } from '../sms/config.js';
|
||||||
import { claimOnce, checkSmsRateLimit, hashPhone, getPendingProposalByPhone, updateProposalStatus } from '../db.js';
|
import { claimOnce, checkSmsRateLimit, hashPhone, getPendingProposalByPhone, updateProposalStatus } from '../db.js';
|
||||||
import { logger, maskPhone } from '../logger.js';
|
import { logger, maskPhone } from '../logger.js';
|
||||||
|
|
||||||
|
const VONAGE_API_SIGNATURE_SECRET = process.env.VONAGE_API_SIGNATURE_SECRET || '';
|
||||||
|
|
||||||
export interface WebhookSmsRouterDeps {
|
export interface WebhookSmsRouterDeps {
|
||||||
queue: EditQueue;
|
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 {
|
export function createWebhookSmsRouter(deps: WebhookSmsRouterDeps): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/telnyx', (req: Request, res: Response) => {
|
// Vonage inbound message webhook
|
||||||
// Respond quickly
|
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' });
|
res.status(200).json({ status: 'received' });
|
||||||
|
|
||||||
// Process async
|
// 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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) {
|
async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) {
|
||||||
const parsed = parseTelnyxInboundMessage(body);
|
const parsed = parseVonageInboundMessage(body);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
logger.warn({ event: 'sms.parse_failed' }, 'Failed to parse inbound SMS');
|
logger.warn({ event: 'sms.parse_failed' }, 'Failed to parse inbound SMS');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function loadConfig(): SmsSitesConfig | null {
|
|||||||
return cachedConfig; // Return stale cache if available, otherwise 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[] {
|
function getOwnNumbers(): string[] {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
if (!config) return [];
|
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).
|
* Check if a phone number is one of our own system numbers (the Vonage numbers we send from).
|
||||||
* Used to filter out Telnyx delivery receipts / echo of our own sent messages.
|
* Used to filter out delivery receipts / echo of our own sent messages.
|
||||||
*/
|
*/
|
||||||
export function isOwnNumber(phone: string): boolean {
|
export function isOwnNumber(phone: string): boolean {
|
||||||
return getOwnNumbers().includes(phone);
|
return getOwnNumbers().includes(phone);
|
||||||
@@ -76,4 +76,4 @@ export function findAuthorizedSite(from: string, to: string): { siteId: string;
|
|||||||
);
|
);
|
||||||
if (!site) return null;
|
if (!site) return null;
|
||||||
return { siteId: site.siteId, repoRoot: site.repoRoot || '.' };
|
return { siteId: site.siteId, repoRoot: site.repoRoot || '.' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,24 +7,72 @@ export interface ParsedInboundSms {
|
|||||||
mediaUrls: string[];
|
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 {
|
try {
|
||||||
const data = body as Record<string, unknown>;
|
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 from = (data.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 to = (data.to as string) || '';
|
||||||
const text = (payload.text as string) || (payload.body as string) || '';
|
const text = (data.text as string) || '';
|
||||||
const messageId = (payload.id as string) || (eventData.id 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 }>) || [];
|
// Collect media URLs from MMS messages
|
||||||
const mediaUrls = media.map(m => m.url).filter(Boolean);
|
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 {
|
} catch {
|
||||||
return null;
|
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';
|
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> {
|
export async function sendSms(to: string, from: string, body: string): Promise<void> {
|
||||||
if (!TELNYX_API_KEY) {
|
const client = getVonageClient();
|
||||||
logger.warn({ event: 'sms.send_skipped', to: maskPhone(to) }, 'No TELNYX_API_KEY, skipping SMS send');
|
|
||||||
|
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)');
|
logger.info({ event: 'sms.would_send', body }, 'SMS body (dev mode)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Strip leading '+' — Vonage expects bare numbers
|
||||||
const resp = await fetch('https://api.telnyx.com/v2/messages', {
|
const toNumber = to.replace(/^\+/, '');
|
||||||
method: 'POST',
|
const fromNumber = from.replace(/^\+/, '');
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${TELNYX_API_KEY}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ from, to, text: body }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
try {
|
||||||
logger.error({ event: 'sms.send_failed', status: resp.status }, 'Failed to send SMS');
|
await client.messages.send(
|
||||||
} else {
|
new SMS({ to: toNumber, from: fromNumber, text: body })
|
||||||
logger.info({ event: 'sms.sent', to: maskPhone(to) }, 'SMS sent');
|
);
|
||||||
}
|
|
||||||
|
logger.info({ event: 'sms.sent', to: maskPhone(to) }, 'SMS sent');
|
||||||
} catch (err) {
|
} 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 = {
|
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.`,
|
`Proposed change: ${summary}\n\nReply YES to apply or NO to cancel.`,
|
||||||
|
|
||||||
APPLIED: (summary: string) =>
|
APPLIED: (summary: string) =>
|
||||||
|
|||||||
@@ -109,8 +109,10 @@ export const smsSitesConfigSchema = z.object({
|
|||||||
export type SmsSitesConfig = z.infer<typeof smsSitesConfigSchema>;
|
export type SmsSitesConfig = z.infer<typeof smsSitesConfigSchema>;
|
||||||
|
|
||||||
// ── Edit Request ──
|
// ── 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({
|
export const editRequestSchema = z.object({
|
||||||
message: z.string().min(1),
|
message: z.string().default(''),
|
||||||
repo_relative_path: z.string().optional(),
|
repo_relative_path: z.string().optional(),
|
||||||
proposal_id: z.string().optional(),
|
proposal_id: z.string().optional(),
|
||||||
confirm: z.enum(['yes', 'no']).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 {
|
interface ManifestEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,28 +28,39 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
|||||||
const [proposalId, setProposalId] = useState<string | null>(null);
|
const [proposalId, setProposalId] = useState<string | null>(null);
|
||||||
const [proposalSummary, setProposalSummary] = useState<string>('');
|
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}`,
|
'Authorization': `Bearer ${apiSecret}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
});
|
||||||
|
|
||||||
|
// Clean up polling interval on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchManifest = useCallback(async () => {
|
const fetchManifest = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers });
|
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers: headers.current });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setSections(data.sections || []);
|
setSections(data.sections || []);
|
||||||
} catch (err) {
|
} catch {
|
||||||
setStatus('Failed to load sections');
|
setStatus('Failed to load sections');
|
||||||
}
|
}
|
||||||
}, [orchestratorUrl, apiSecret]);
|
}, [orchestratorUrl]);
|
||||||
|
|
||||||
useEffect(() => { fetchManifest(); }, [fetchManifest]);
|
useEffect(() => { fetchManifest(); }, [fetchManifest]);
|
||||||
|
|
||||||
const loadSection = async (entry: ManifestEntry) => {
|
const loadSection = async (entry: ManifestEntry) => {
|
||||||
setSelectedSection(entry);
|
setSelectedSection(entry);
|
||||||
setView('edit');
|
setView('edit');
|
||||||
|
setStatus('');
|
||||||
try {
|
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();
|
const data = await res.json();
|
||||||
setSectionJson(JSON.stringify(data, null, 2));
|
setSectionJson(JSON.stringify(data, null, 2));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -60,21 +71,32 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
|||||||
const saveSection = async () => {
|
const saveSection = async () => {
|
||||||
if (!selectedSection) return;
|
if (!selectedSection) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatus('Saving...');
|
setStatus('Validating & saving...');
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(sectionJson);
|
const parsed = JSON.parse(sectionJson);
|
||||||
const res = await fetch(`${orchestratorUrl}/api/edit`, {
|
|
||||||
method: 'POST',
|
// Write the JSON directly via PUT /api/section
|
||||||
headers,
|
const res = await fetch(`${orchestratorUrl}/api/section`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: headers.current,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: `Direct JSON update to ${selectedSection.repo_relative_path}`,
|
path: selectedSection.repo_relative_path,
|
||||||
repo_relative_path: selectedSection.repo_relative_path,
|
data: parsed,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const result = await res.json();
|
||||||
setStatus(data.status === 'processing' ? 'Edit submitted — processing...' : `Status: ${data.status}`);
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
setStatus('Save failed');
|
setStatus(`Invalid JSON: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@@ -85,16 +107,22 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
|||||||
setStatus('Sending to AI...');
|
setStatus('Sending to AI...');
|
||||||
setProposalId(null);
|
setProposalId(null);
|
||||||
setProposalSummary('');
|
setProposalSummary('');
|
||||||
|
|
||||||
|
// Clear any existing poll
|
||||||
|
if (pollIntervalRef.current) {
|
||||||
|
clearInterval(pollIntervalRef.current);
|
||||||
|
pollIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${orchestratorUrl}/api/edit`, {
|
const res = await fetch(`${orchestratorUrl}/api/edit`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers: headers.current,
|
||||||
body: JSON.stringify({ message: nlMessage }),
|
body: JSON.stringify({ message: nlMessage }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.job_id) {
|
if (data.job_id) {
|
||||||
setStatus('Processing... checking for proposal.');
|
setStatus('Processing... waiting for AI proposal.');
|
||||||
// Poll for proposal
|
|
||||||
pollForProposal(data.job_id);
|
pollForProposal(data.job_id);
|
||||||
} else {
|
} else {
|
||||||
setStatus(`Response: ${JSON.stringify(data)}`);
|
setStatus(`Response: ${JSON.stringify(data)}`);
|
||||||
@@ -105,22 +133,36 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pollForProposal = async (jobId: string) => {
|
const pollForProposal = (jobId: string) => {
|
||||||
// Simple poll: check recent proposals. In production, use SSE or websockets.
|
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const interval = setInterval(async () => {
|
|
||||||
|
pollIntervalRef.current = setInterval(async () => {
|
||||||
attempts++;
|
attempts++;
|
||||||
if (attempts > 30) {
|
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.');
|
setStatus('Timed out waiting for proposal. The AI may still be processing.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Re-fetch manifest to see if anything changed, or check proposal endpoint
|
const res = await fetch(`${orchestratorUrl}/api/job/${jobId}`, { headers: headers.current });
|
||||||
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers });
|
|
||||||
const data = await res.json();
|
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);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,13 +172,14 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${orchestratorUrl}/api/edit`, {
|
const res = await fetch(`${orchestratorUrl}/api/edit`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers: headers.current,
|
||||||
body: JSON.stringify({ message: '', confirm, proposal_id: proposalId }),
|
body: JSON.stringify({ confirm, proposal_id: proposalId }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
await res.json();
|
||||||
setStatus(confirm === 'yes' ? 'Applied! Refreshing...' : 'Cancelled.');
|
setStatus(confirm === 'yes' ? 'Applied! Refreshing...' : 'Cancelled.');
|
||||||
setProposalId(null);
|
setProposalId(null);
|
||||||
setProposalSummary('');
|
setProposalSummary('');
|
||||||
|
setNlMessage('');
|
||||||
if (confirm === 'yes') {
|
if (confirm === 'yes') {
|
||||||
setTimeout(() => { fetchManifest(); }, 1000);
|
setTimeout(() => { fetchManifest(); }, 1000);
|
||||||
}
|
}
|
||||||
@@ -232,8 +275,10 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
|||||||
onChange={e => setSectionJson(e.target.value)}
|
onChange={e => setSectionJson(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
|
||||||
<button style={styles.btn} onClick={saveSection} disabled={loading}>Save</button>
|
<button style={styles.btn} onClick={saveSection} disabled={loading}>
|
||||||
<button style={styles.btnOutline} onClick={() => { setView('sections'); setSelectedSection(null); }}>Back</button>
|
{loading ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button style={styles.btnOutline} onClick={() => { setView('sections'); setSelectedSection(null); setStatus(''); }}>Back</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -243,15 +288,15 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<h3 style={styles.subhead}>Describe Your Edit</h3>
|
<h3 style={styles.subhead}>Describe Your Edit</h3>
|
||||||
<p style={{ fontSize: '0.9rem', color: '#6b6b6b', marginBottom: '1rem' }}>
|
<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"
|
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".
|
or “Hide the promo banner” or “Add a new event on May 15th”.
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Describe what you want to change..."
|
placeholder="Describe what you want to change..."
|
||||||
value={nlMessage}
|
value={nlMessage}
|
||||||
onChange={e => setNlMessage(e.target.value)}
|
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()}>
|
<button style={styles.btn} onClick={submitNlEdit} disabled={loading || !nlMessage.trim()}>
|
||||||
{loading ? 'Processing...' : 'Submit Edit'}
|
{loading ? 'Processing...' : 'Submit Edit'}
|
||||||
@@ -262,8 +307,8 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
|||||||
<strong>Proposed change:</strong>
|
<strong>Proposed change:</strong>
|
||||||
<p style={{ margin: '0.5rem 0' }}>{proposalSummary}</p>
|
<p style={{ margin: '0.5rem 0' }}>{proposalSummary}</p>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
<button style={styles.btn} onClick={() => confirmProposal('yes')}>Yes, Apply</button>
|
<button style={styles.btn} onClick={() => confirmProposal('yes')} disabled={loading}>Yes, Apply</button>
|
||||||
<button style={styles.btnDanger} onClick={() => confirmProposal('no')}>No, Cancel</button>
|
<button style={styles.btnDanger} onClick={() => confirmProposal('no')} disabled={loading}>No, Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -271,7 +316,7 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Create Section ── */}
|
{/* ── 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>}
|
{status && <p style={styles.status}>{status}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,37 +3,63 @@ import path from 'node:path';
|
|||||||
import { parseSiteBundle, type SiteBundle } from './site-bundle.ts';
|
import { parseSiteBundle, type SiteBundle } from './site-bundle.ts';
|
||||||
|
|
||||||
const REPO_ROOT = process.env.REPO_ROOT || '.';
|
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;
|
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 {
|
export function loadSiteData(): SiteBundle {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (cached && now - cached.loadedAt < TTL) {
|
if (cached && now - cached.loadedAt < TTL) {
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const siteContextRaw = JSON.parse(
|
try {
|
||||||
fs.readFileSync(path.join(REPO_ROOT, 'site-context.json'), 'utf-8')
|
const siteContextRaw = JSON.parse(
|
||||||
);
|
fs.readFileSync(path.join(REPO_ROOT, 'site-context.json'), 'utf-8')
|
||||||
|
);
|
||||||
|
|
||||||
const eventsRaw = JSON.parse(
|
let eventsRaw: unknown = { events: [] };
|
||||||
fs.readFileSync(path.join(REPO_ROOT, 'content/events.json'), 'utf-8')
|
const eventsPath = path.join(REPO_ROOT, 'content/events.json');
|
||||||
);
|
if (fs.existsSync(eventsPath)) {
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
sectionRaws.push(JSON.parse(fs.readFileSync(path.join(sectionsDir, file), 'utf-8')));
|
eventsRaw = JSON.parse(fs.readFileSync(eventsPath, 'utf-8'));
|
||||||
} catch {
|
} catch {
|
||||||
// skip invalid files
|
// Use empty events if file is corrupt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const data = parseSiteBundle(siteContextRaw, eventsRaw, sectionRaws);
|
const sectionsDir = path.join(REPO_ROOT, 'content/sections');
|
||||||
cached = { data, loadedAt: now };
|
const sectionRaws: unknown[] = [];
|
||||||
return data;
|
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