metatroncubeswdev c404ea2ef9 Research: Google Places ratings, Google Trends, Instagram hashtag top posts
- 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>
2026-10-27 18:51:54 -04:00

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) };
}