- Places API (New): competitor scans find local rivals on Google Maps and the server reads real ratings/review counts by place id; client's own listing fills the "You" row; recent reviews for praise/complaint themes; 30-day cache - Google Trends via DataForSEO: rising/top related searches and % change per keyword in the client's region; used by trend-scan and to check gap demand - Instagram Hashtag Search: top/recent posts under a niche hashtag (tool + Competitors page panel), counts Instagram's 30 tags/week limit - Server settings → Research: keys plus on/off switches for each source (hashtag search off until Meta approves Public Content Access) - Reports mark website-sourced ratings as unverified; skill takes handles only from a business's own site Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
8.3 KiB
JavaScript
127 lines
8.3 KiB
JavaScript
// Server settings an admin can change on the site instead of in .env (Settings → Server settings).
|
|
// Saved values are sealed in data/settings.json and override .env in every process (see connector/src/env.js).
|
|
// Scopes: "all" = every workspace; "self" = hidden in hosted agency workspaces (we provide those);
|
|
// "control" = only on the control centre (app.quorlasocial.com). Secrets are never sent back to the browser.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { SETTINGS_FILE, ENV_BASE, savedSettings, applySetting } from "../../connector/src/env.js";
|
|
import { seal } from "../../connector/src/sealed.js";
|
|
import * as control from "./control.js";
|
|
|
|
const bad = (message, status = 400) => Object.assign(new Error(message), { status });
|
|
|
|
const GROUPS = [
|
|
{ name: "General", fields: [
|
|
{ key: "PUBLIC_URL", label: "Public address", hint: "Where this dashboard is reached, e.g. https://social.youragency.com. Used in email links and for platforms that fetch media.", scope: "self", type: "url" },
|
|
{ key: "DASHBOARD_SESSION_HOURS", label: "Stay signed in (hours)", type: "number", placeholder: "12", restart: true },
|
|
] },
|
|
{ name: "Publishing", fields: [
|
|
{ key: "ALLOW_PUBLISH", label: "Allow publishing", hint: "Off: drafts can be prepared but nothing is posted. Every post still needs approval.", type: "bool", default: false },
|
|
{ key: "SCHEDULER_ENABLED", label: "Run the scheduler", hint: "Posts approved posts at their time.", type: "bool", default: true, restart: true },
|
|
{ key: "DASHBOARD_NIGHTLY_REFRESH", label: "Nightly numbers refresh and monthly reports", type: "bool", default: true, restart: true },
|
|
] },
|
|
{ name: "Meta", fields: [
|
|
{ key: "META_MEDIA_PAGE_ID", label: "Agency Facebook Page ID", hint: "Needed to post images for Instagram-only clients." },
|
|
{ key: "META_API_VERSION", label: "Graph API version", placeholder: "v23.0", scope: "self", restart: true },
|
|
] },
|
|
{ name: "AI runs", fields: [
|
|
{ key: "ANTHROPIC_API_KEY", label: "Anthropic API key", hint: "For audits, weekly plans and reports.", secret: true },
|
|
{ key: "DASHBOARD_AI_MODEL", label: "Model", placeholder: "claude-opus-5-5" },
|
|
{ key: "DASHBOARD_AI_MAX_TURNS", label: "Max steps per run", type: "number", placeholder: "80" },
|
|
{ key: "ANTHROPIC_BASE_URL", label: "Model server address", type: "url", placeholder: "http://172.17.0.1:11434", hint: "Leave empty to use Anthropic. For your own Ollama, enter its address and set Model to the Ollama model name (e.g. qwen3.6:35b-a3b)." },
|
|
{ key: "ANTHROPIC_AUTH_TOKEN", label: "Model server token", secret: true, hint: "Only with a model server address. Ollama accepts any text, e.g. ollama." },
|
|
] },
|
|
{ name: "Research", fields: [
|
|
{ key: "RESEARCH_GOOGLE_PLACES", label: "Google ratings & reviews (Places)", hint: "Competitor scans read real Google ratings and reviews. Needs the key below. Off: scans use what websites show (marked unverified).", type: "bool", default: true },
|
|
{ key: "RESEARCH_GOOGLE_TRENDS", label: "Google search trends (DataForSEO)", hint: "Trend scans and competitor scans check what people search for in the client's region. Needs the DataForSEO login below.", type: "bool", default: true },
|
|
{ key: "RESEARCH_IG_HASHTAGS", label: "Instagram hashtag top posts", hint: "Trend scans find top Reels under niche hashtags. Turn on only after Meta approves Instagram Public Content Access. 30 hashtags per client account per week.", type: "bool", default: false },
|
|
{ key: "GOOGLE_PLACES_API_KEY", label: "Google Places API key", hint: "Real Google ratings and reviews for clients and competitors. Restrict the key to Places API (New) and this server's IP.", secret: true },
|
|
{ key: "DATAFORSEO_LOGIN", label: "DataForSEO API login", hint: "For Google Trends (rising searches in a client's niche). DataForSEO dashboard → API Access." },
|
|
{ key: "DATAFORSEO_PASSWORD", label: "DataForSEO API password", secret: true },
|
|
] },
|
|
{ name: "Email (SMTP)", scope: "self", fields: [
|
|
{ key: "SMTP_HOST", label: "Host", placeholder: "mail.example.com" },
|
|
{ key: "SMTP_PORT", label: "Port", type: "number", placeholder: "587" },
|
|
{ key: "SMTP_USER", label: "Username" },
|
|
{ key: "SMTP_PASS", label: "Password", secret: true },
|
|
{ key: "SMTP_FROM", label: "From address", placeholder: "Quorla <support@example.com>" },
|
|
] },
|
|
{ name: "Platforms", scope: "self", fields: [
|
|
{ key: "YOUTUBE_API_KEY", label: "YouTube Data API key", hint: "Reads public comments for the Inbox.", secret: true },
|
|
{ key: "LINKEDIN_API_VERSION", label: "LinkedIn API version", placeholder: "202606" },
|
|
{ key: "PINTEREST_SANDBOX", label: "Pinterest sandbox (Trial access)", type: "bool", default: false },
|
|
{ key: "REDDIT_USER_AGENT", label: "Reddit user agent", placeholder: "web:com.youragency.app:1.0 (by /u/yourname)" },
|
|
] },
|
|
{ name: "Billing & sign-ups", scope: "control", fields: [
|
|
{ key: "STRIPE_SECRET_KEY", label: "Stripe secret key", secret: true },
|
|
{ key: "STRIPE_PRICE_ID", label: "Stripe price ID", placeholder: "price_…" },
|
|
{ key: "STRIPE_WEBHOOK_SECRET", label: "Stripe webhook secret", secret: true, hint: "Webhook URL: /control/stripe/webhook on this site." },
|
|
{ key: "STRIPE_TRIAL_DAYS", label: "Free trial (days)", type: "number", placeholder: "0" },
|
|
{ key: "TENANT_BASE_DOMAIN", label: "Agency workspace domain", placeholder: "quorlasocial.com" },
|
|
] },
|
|
];
|
|
|
|
const hosted = () => process.env.QUORLA_HOSTED_TENANT === "true";
|
|
const visible = (scope) => scope === "control" ? control.enabled() : scope === "self" ? !hosted() : true;
|
|
const visibleFields = () => GROUPS.flatMap((g) => (visible(g.scope) ? g.fields.filter((f) => visible(f.scope)) : []));
|
|
|
|
/** Everything the Settings page needs. Secrets come back only as set / not set. */
|
|
export function list() {
|
|
const saved = savedSettings();
|
|
return {
|
|
hosted: hosted(),
|
|
canRestart: fs.existsSync("/.dockerenv"),
|
|
groups: GROUPS.filter((g) => visible(g.scope)).map((g) => ({
|
|
name: g.name,
|
|
fields: g.fields.filter((f) => visible(f.scope)).map((f) => {
|
|
const value = process.env[f.key];
|
|
const fromEnv = (f.key in ENV_BASE ? ENV_BASE[f.key] : value) !== undefined;
|
|
return {
|
|
key: f.key, label: f.label, hint: f.hint || "", placeholder: f.placeholder || "", type: f.type || "text", secret: Boolean(f.secret), restart: Boolean(f.restart),
|
|
default: f.default ?? null,
|
|
source: f.key in saved ? "site" : fromEnv ? "env" : null,
|
|
set: Boolean(value),
|
|
value: f.secret ? "" : value ?? "",
|
|
};
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function clean(f, raw) {
|
|
const v = String(raw ?? "").trim();
|
|
if (/[\r\n]/.test(v)) throw bad(`${f.label}: one line only.`);
|
|
if (f.type === "number" && !/^\d+$/.test(v)) throw bad(`${f.label} must be a whole number.`);
|
|
if (f.type === "bool" && !["true", "false"].includes(v)) throw bad(`${f.label} must be on or off.`);
|
|
if (f.type === "url" && !/^https?:\/\/[^\s/]+/.test(v)) throw bad(`${f.label} must start with https://`);
|
|
return f.type === "url" ? v.replace(/\/+$/, "") : v;
|
|
}
|
|
|
|
/**
|
|
* values: { KEY: "new value" } (an empty secret keeps the stored one); clear: [KEY] goes back to .env.
|
|
* Returns the fresh list plus which saved keys need a restart.
|
|
*/
|
|
export function save({ values = {}, clear = [] } = {}, onChange = () => {}) {
|
|
const fields = new Map(visibleFields().map((f) => [f.key, f]));
|
|
const next = savedSettings();
|
|
const changed = [];
|
|
for (const k of [...Object.keys(values), ...clear]) if (!fields.has(k)) throw bad(`${k} can't be changed here.`);
|
|
for (const [k, raw] of Object.entries(values)) {
|
|
const f = fields.get(k);
|
|
if (f.secret && !String(raw ?? "").trim()) continue;
|
|
if (!f.secret && !String(raw ?? "").trim()) { if (k in next) { delete next[k]; changed.push(k); } continue; }
|
|
const v = clean(f, raw);
|
|
if (next[k] !== v) { next[k] = v; changed.push(k); }
|
|
}
|
|
for (const k of clear) if (k in next) { delete next[k]; changed.push(k); }
|
|
if (changed.length) {
|
|
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
|
|
const tmp = `${SETTINGS_FILE}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify({ sealed: seal(next), updatedAt: new Date().toISOString() }), { mode: 0o600 });
|
|
fs.renameSync(tmp, SETTINGS_FILE);
|
|
for (const k of changed) applySetting(k, next[k] ?? null);
|
|
onChange(changed);
|
|
}
|
|
return { ...list(), changed, restart: changed.filter((k) => fields.get(k).restart) };
|
|
}
|