288 lines
12 KiB
JavaScript
288 lines
12 KiB
JavaScript
/// <reference path="../pb_data/types.d.ts" />
|
|
//
|
|
// Initial PocketBase schema for Bettersight — MVP (Analyse tier) scope only.
|
|
// Discover/Intelligence-only collections (signal_events, competitor_people,
|
|
// wayback_snapshots, tenant_custom_fields, etc.) are intentionally excluded
|
|
// per CLAUDE.md §27 "What NOT to Build" — add them in a later migration
|
|
// when those tiers are actually built.
|
|
//
|
|
// Written against PocketBase's JS migration API (v0.23+). Collections are
|
|
// created in dependency order so relation fields can reference the
|
|
// already-created target collection's id directly.
|
|
|
|
migrate((app) => {
|
|
// ── small helpers to keep 15 collection definitions readable ──────────
|
|
const text = (name, opts = {}) => ({ name, type: 'text', ...opts });
|
|
const url = (name, opts = {}) => ({ name, type: 'url', ...opts });
|
|
const num = (name, opts = {}) => ({ name, type: 'number', ...opts });
|
|
const boolean = (name, opts = {}) => ({ name, type: 'bool', ...opts });
|
|
const json = (name, opts = {}) => ({ name, type: 'json', ...opts });
|
|
const date = (name, opts = {}) => ({ name, type: 'date', ...opts });
|
|
const autodate = (name, onCreate, onUpdate = false) => ({
|
|
name, type: 'autodate', onCreate, onUpdate,
|
|
});
|
|
const select = (name, values, opts = {}) => ({
|
|
name, type: 'select', maxSelect: 1, values, ...opts,
|
|
});
|
|
const rel = (name, collection, opts = {}) => ({
|
|
name, type: 'relation', collectionId: collection.id,
|
|
cascadeDelete: false, minSelect: 0, maxSelect: 1, ...opts,
|
|
});
|
|
|
|
const mk = (name, fields, listRule = null) => {
|
|
const collection = new Collection({
|
|
type: 'base',
|
|
name,
|
|
fields,
|
|
// Tenant-scoped data — API access enforced via the Flask backend's
|
|
// X-API-Key + tenant_id filtering, not PocketBase's own auth rules.
|
|
// Leaving rules null restricts direct API access to admin-only,
|
|
// which is correct since only the Flask service account talks to
|
|
// PocketBase directly.
|
|
listRule,
|
|
viewRule: listRule,
|
|
createRule: null,
|
|
updateRule: null,
|
|
deleteRule: null,
|
|
});
|
|
return app.save(collection) && collection;
|
|
};
|
|
|
|
// ── tenants ─────────────────────────────────────────────────────────
|
|
const tenants = mk('tenants', [
|
|
text('name', { required: true }),
|
|
text('email', { required: true }),
|
|
text('domain', { required: true }),
|
|
select('tier', ['analyse', 'discover', 'intelligence', 'enterprise'], { required: true }),
|
|
select('status', ['trial', 'active', 'inactive'], { required: true }),
|
|
date('trial_ends_at'),
|
|
num('max_seats', { required: true }),
|
|
text('timezone'),
|
|
text('stripe_customer_id'),
|
|
text('stripe_subscription_id'),
|
|
json('onboarding_checklist'),
|
|
text('data_region'),
|
|
autodate('created', true),
|
|
]);
|
|
|
|
// ── tenant_seats ────────────────────────────────────────────────────
|
|
mk('tenant_seats', [
|
|
rel('tenant_id', tenants, { required: true }),
|
|
text('email', { required: true }),
|
|
boolean('active'),
|
|
date('last_accessed'),
|
|
text('added_by'),
|
|
autodate('created', true),
|
|
]);
|
|
|
|
// ── competitors ─────────────────────────────────────────────────────
|
|
const competitors = mk('competitors', [
|
|
rel('tenant_id', tenants, { required: true }),
|
|
text('name', { required: true }),
|
|
url('website', { required: true }),
|
|
url('catalogue_url', { required: true }),
|
|
text('primary_market', { required: true }),
|
|
text('firecrawl_monitor_id'),
|
|
boolean('change_detected'),
|
|
date('change_detected_at'),
|
|
boolean('active'),
|
|
date('last_scraped'),
|
|
autodate('created', true),
|
|
]);
|
|
|
|
// ── products ────────────────────────────────────────────────────────
|
|
const products = mk('products', [
|
|
rel('tenant_id', tenants, { required: true }),
|
|
rel('competitor_id', competitors, { required: true }),
|
|
text('trip_name'),
|
|
url('url'),
|
|
text('destination'),
|
|
num('duration_days'),
|
|
num('majority_price_usd'),
|
|
num('price_low_usd'),
|
|
num('price_high_usd'),
|
|
num('aud_price'),
|
|
num('cad_price'),
|
|
num('eur_price'),
|
|
num('gbp_price'),
|
|
text('date_used'),
|
|
text('seasonality'),
|
|
num('group_size'),
|
|
num('meals'),
|
|
text('service_level'),
|
|
text('activities'),
|
|
text('start_location'),
|
|
text('end_location'),
|
|
text('target_audience'),
|
|
text('hotels'),
|
|
text('comments'),
|
|
num('relevancy'),
|
|
text('trip_code'),
|
|
boolean('is_new'),
|
|
autodate('first_seen', true),
|
|
date('last_seen'),
|
|
boolean('is_active'),
|
|
json('embedding'),
|
|
]);
|
|
|
|
// ── products_ngs_meta ───────────────────────────────────────────────
|
|
mk('products_ngs_meta', [
|
|
rel('product_id', products, { required: true }),
|
|
text('exclusive_access'),
|
|
text('group_leader'),
|
|
text('sustainability'),
|
|
]);
|
|
|
|
// ── price_history ───────────────────────────────────────────────────
|
|
mk('price_history', [
|
|
rel('tenant_id', tenants, { required: true }),
|
|
rel('product_id', products, { required: true }),
|
|
num('majority_price_usd'),
|
|
num('price_low_usd'),
|
|
num('price_high_usd'),
|
|
num('aud_price'),
|
|
num('cad_price'),
|
|
num('eur_price'),
|
|
num('gbp_price'),
|
|
text('date_used'),
|
|
text('change_field'),
|
|
num('change_amount'),
|
|
num('change_percent'),
|
|
autodate('scraped_at', true),
|
|
]);
|
|
|
|
// ── scrape_runs ─────────────────────────────────────────────────────
|
|
// Note: duration_seconds is not in CLAUDE.md §6's scrape_runs schema
|
|
// block, but §29 Component 2 (job duration alerting) explicitly writes
|
|
// scrape_repository.update(job_id, {'duration_seconds': duration}) —
|
|
// added here so that hook has somewhere to write.
|
|
mk('scrape_runs', [
|
|
rel('tenant_id', tenants, { required: true }),
|
|
rel('competitor_id', competitors, { required: false }),
|
|
select('triggered_by', ['cron', 'webhook', 'manual', 'sheet'], { required: true }),
|
|
select('status', ['pending', 'running', 'complete', 'failed'], { required: true }),
|
|
num('competitors_total'),
|
|
num('competitors_done'),
|
|
json('results'),
|
|
text('error_log'),
|
|
num('duration_seconds'),
|
|
autodate('started_at', true),
|
|
date('completed_at'),
|
|
]);
|
|
|
|
// ── alerts ──────────────────────────────────────────────────────────
|
|
mk('alerts', [
|
|
rel('tenant_id', tenants, { required: true }),
|
|
rel('competitor_id', competitors, { required: true }),
|
|
rel('product_id', products, { required: false }),
|
|
select('alert_type', ['price_change', 'new_product', 'product_removed', 'new_comparable'], { required: true }),
|
|
text('message'),
|
|
num('change_amount'),
|
|
num('change_percent'),
|
|
boolean('delivered_dashboard'),
|
|
boolean('delivered_email'),
|
|
boolean('delivered_slack'),
|
|
boolean('delivered_teams'),
|
|
boolean('delivered_whatsapp'),
|
|
boolean('read'),
|
|
autodate('created', true),
|
|
]);
|
|
|
|
// ── battlecards ─────────────────────────────────────────────────────
|
|
mk('battlecards', [
|
|
rel('tenant_id', tenants, { required: true }),
|
|
rel('competitor_id', competitors, { required: true }),
|
|
text('content'),
|
|
json('content_json'),
|
|
autodate('generated_at', true),
|
|
]);
|
|
|
|
// ── comparable_matches ──────────────────────────────────────────────
|
|
mk('comparable_matches', [
|
|
rel('tenant_id', tenants, { required: true }),
|
|
text('client_product'),
|
|
rel('competitor_product', products, { required: true }),
|
|
num('similarity_score'),
|
|
num('price_difference'),
|
|
num('duration_difference'),
|
|
autodate('first_matched', true),
|
|
date('last_matched'),
|
|
boolean('dismissed'),
|
|
]);
|
|
|
|
// ── tenant_notifications ────────────────────────────────────────────
|
|
// Slack/Teams/WhatsApp fields are schema-present per CLAUDE.md §6 but
|
|
// unused in the Analyse-tier MVP — those integrations are Discover
|
|
// tier only (§27). email_digest fields are the only ones written to
|
|
// until Discover tier work begins.
|
|
mk('tenant_notifications', [
|
|
rel('tenant_id', tenants, { required: true }),
|
|
text('slack_webhook_url'),
|
|
text('teams_webhook_url'),
|
|
text('whatsapp_number'),
|
|
boolean('email_digest'),
|
|
text('email_digest_time'),
|
|
autodate('created', true),
|
|
]);
|
|
|
|
// ── digest_logs ─────────────────────────────────────────────────────
|
|
mk('digest_logs', [
|
|
rel('tenant_id', tenants, { required: true }),
|
|
select('digest_type', ['weekly', 'monthly'], { required: true }),
|
|
text('pdf_path'),
|
|
autodate('sent_at', true),
|
|
text('recipient_email'),
|
|
select('status', ['sent', 'failed'], { required: true }),
|
|
]);
|
|
|
|
// ── proxy_overrides ─────────────────────────────────────────────────
|
|
mk('proxy_overrides', [
|
|
rel('tenant_id', tenants, { required: false }),
|
|
text('domain', { required: true }),
|
|
select('provider', ['webshare', 'brightdata'], { required: true }),
|
|
num('blocked_count'),
|
|
date('last_blocked'),
|
|
autodate('created', true),
|
|
]);
|
|
|
|
// ── client_trips ────────────────────────────────────────────────────
|
|
mk('client_trips', [
|
|
rel('tenant_id', tenants, { required: true }),
|
|
text('trip_name'),
|
|
text('destination'),
|
|
num('duration_days'),
|
|
num('price_usd'),
|
|
text('activities'),
|
|
json('embedding'),
|
|
autodate('created', true),
|
|
]);
|
|
|
|
// ── monitoring_events ───────────────────────────────────────────────
|
|
// Owner-only (Gotify) monitoring history — never tenant-scoped.
|
|
mk('monitoring_events', [
|
|
select('event_type', [
|
|
'queue_depth', 'job_duration', 'api_health', 'worker_health',
|
|
'cron_duration', 'failure_rate', 'fast_path_ratio', 'weekly_summary',
|
|
], { required: true }),
|
|
select('severity', ['info', 'advisory', 'warning', 'critical'], { required: true }),
|
|
num('value'),
|
|
num('threshold'),
|
|
text('message'),
|
|
boolean('alert_sent'),
|
|
autodate('created', true),
|
|
]);
|
|
|
|
}, (app) => {
|
|
// ── revert: delete in reverse dependency order ─────────────────────
|
|
const names = [
|
|
'monitoring_events', 'client_trips', 'proxy_overrides', 'digest_logs',
|
|
'tenant_notifications', 'comparable_matches', 'battlecards', 'alerts',
|
|
'scrape_runs', 'price_history', 'products_ngs_meta', 'products',
|
|
'competitors', 'tenant_seats', 'tenants',
|
|
];
|
|
for (const name of names) {
|
|
const collection = app.findCollectionByNameOrId(name);
|
|
if (collection) app.delete(collection);
|
|
}
|
|
});
|