Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
-- name: CreateAPIKey :one
INSERT INTO api_keys (company_id, user_id, name, key_hash, key_prefix)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: ListAPIKeysByCompany :many
SELECT id, company_id, user_id, name, key_prefix, last_used_at, revoked_at, created_at, updated_at
FROM api_keys
WHERE company_id = $1 AND revoked_at IS NULL
ORDER BY created_at DESC;
-- name: GetAPIKeyByHash :one
SELECT * FROM api_keys
WHERE key_hash = $1 AND revoked_at IS NULL
LIMIT 1;
-- name: RevokeAPIKey :one
UPDATE api_keys
SET revoked_at = now(), updated_at = now()
WHERE id = $1 AND company_id = $2
RETURNING *;
-- name: TouchAPIKey :exec
UPDATE api_keys SET last_used_at = now() WHERE id = $1;
+34
View File
@@ -0,0 +1,34 @@
-- name: ListAttributes :many
SELECT * FROM attributes
WHERE company_id = $1
ORDER BY name;
-- name: GetAttribute :one
SELECT * FROM attributes
WHERE id = $1 AND company_id = $2
LIMIT 1;
-- name: CreateAttribute :one
INSERT INTO attributes (company_id, attribute_key, name, value_type, unit, example, parent_key)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: UpdateAttribute :one
UPDATE attributes
SET name = COALESCE($3, name),
value_type = COALESCE($4, value_type),
unit = COALESCE($5, unit),
example = COALESCE($6, example),
updated_at = now()
WHERE id = $1 AND company_id = $2
RETURNING *;
-- name: DeleteAttribute :exec
DELETE FROM attributes WHERE id = $1 AND company_id = $2;
-- name: ListCategoryAttributes :many
SELECT ca.*, a.attribute_key, a.name AS attribute_name, a.value_type
FROM category_attributes ca
JOIN attributes a ON a.id = ca.attribute_id
WHERE ca.company_id = $1 AND ca.category_unique_id = $2
ORDER BY a.name;
+34
View File
@@ -0,0 +1,34 @@
-- name: GetCreditBalance :one
SELECT * FROM credit_balances WHERE company_id = $1 LIMIT 1;
-- name: UpsertCreditBalance :one
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (company_id) DO UPDATE
SET total_credits = EXCLUDED.total_credits,
used_credits = EXCLUDED.used_credits,
updated_at = now()
RETURNING *;
-- name: GetActiveCompanyPlan :one
SELECT cp.*, p.name AS plan_name, p.monthly_credits, p.max_products
FROM company_plans cp
JOIN plans p ON p.id = cp.plan_id
WHERE cp.company_id = $1 AND cp.is_active = true
ORDER BY cp.created_at DESC
LIMIT 1;
-- name: ListPlans :many
SELECT * FROM plans ORDER BY id;
-- name: CreatePlan :one
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: CreateCompanyPlan :one
INSERT INTO company_plans (
company_id, plan_id, is_active, billing_cycle_start, next_billing_date,
is_trial, trial_ends_at, trial_credits
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
+19
View File
@@ -0,0 +1,19 @@
-- name: GetCompanyBrand :one
SELECT * FROM company_brand WHERE company_id = $1 LIMIT 1;
-- name: UpsertCompanyBrand :one
INSERT INTO company_brand (
company_id, voice_tone, dos, donts, primary_color, secondary_color, logo_url, preferred_terms, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, now()
)
ON CONFLICT (company_id) DO UPDATE SET
voice_tone = EXCLUDED.voice_tone,
dos = EXCLUDED.dos,
donts = EXCLUDED.donts,
primary_color = EXCLUDED.primary_color,
secondary_color = EXCLUDED.secondary_color,
logo_url = EXCLUDED.logo_url,
preferred_terms = EXCLUDED.preferred_terms,
updated_at = now()
RETURNING *;
+29
View File
@@ -0,0 +1,29 @@
-- name: ListCategories :many
SELECT * FROM categories
WHERE company_id = $1
ORDER BY path NULLS LAST, position, name;
-- name: GetCategory :one
SELECT * FROM categories
WHERE id = $1 AND company_id = $2
LIMIT 1;
-- name: CreateCategory :one
INSERT INTO categories (
company_id, name, unique_id, parent_unique_id, path, level, position, description
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: UpdateCategory :one
UPDATE categories
SET name = COALESCE($3, name),
description = COALESCE($4, description),
is_active = COALESCE($5, is_active),
title_template = COALESCE($6, title_template),
description_template = COALESCE($7, description_template),
updated_at = now()
WHERE id = $1 AND company_id = $2
RETURNING *;
-- name: DeleteCategory :exec
DELETE FROM categories WHERE id = $1 AND company_id = $2;
+29
View File
@@ -0,0 +1,29 @@
-- name: GetCompanyByID :one
SELECT * FROM companies WHERE id = $1 LIMIT 1;
-- name: GetCompanyByLegacyID :one
SELECT * FROM companies WHERE legacy_company_id = $1 LIMIT 1;
-- name: CreateCompany :one
INSERT INTO companies (name, language, legacy_company_id)
VALUES ($1, $2, $3)
RETURNING *;
-- name: UpdateCompany :one
UPDATE companies
SET name = COALESCE($2, name),
language = COALESCE($3, language),
merge_products_by_gtin = COALESCE($4, merge_products_by_gtin),
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: GetCompanySettings :one
SELECT * FROM company_settings WHERE company_id = $1 LIMIT 1;
-- name: UpsertCompanySettings :one
INSERT INTO company_settings (company_id, settings, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (company_id) DO UPDATE
SET settings = EXCLUDED.settings, updated_at = now()
RETURNING *;
+70
View File
@@ -0,0 +1,70 @@
-- name: ListInputFeeds :many
SELECT * FROM input_feeds
WHERE company_id = $1
ORDER BY created_at DESC;
-- name: GetInputFeed :one
SELECT * FROM input_feeds
WHERE id = $1 AND company_id = $2
LIMIT 1;
-- name: CreateInputFeed :one
INSERT INTO input_feeds (company_id, name, url, feed_type, status, sync_interval_minutes, options)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: UpdateInputFeed :one
UPDATE input_feeds
SET name = COALESCE($3, name),
url = COALESCE($4, url),
status = COALESCE($5, status),
sync_interval_minutes = COALESCE($6, sync_interval_minutes),
options = COALESCE($7, options),
updated_at = now()
WHERE id = $1 AND company_id = $2
RETURNING *;
-- name: DeleteInputFeed :exec
DELETE FROM input_feeds WHERE id = $1 AND company_id = $2;
-- name: CreateFeedSyncJob :one
INSERT INTO feed_sync_jobs (feed_id, company_id, status, started_at)
VALUES ($1, $2, $3, now())
RETURNING *;
-- name: UpdateFeedSyncJob :one
UPDATE feed_sync_jobs
SET status = $2, products_synced = $3, error = $4, completed_at = $5, updated_at = now()
WHERE id = $1
RETURNING *;
-- name: ListExportFeeds :many
SELECT * FROM export_feeds
WHERE company_id = $1
ORDER BY created_at DESC;
-- name: GetExportFeed :one
SELECT * FROM export_feeds
WHERE id = $1 AND company_id = $2
LIMIT 1;
-- name: GetExportFeedByToken :one
SELECT * FROM export_feeds
WHERE public_token = $1 AND is_active = true
LIMIT 1;
-- name: CreateExportFeed :one
INSERT INTO export_feeds (company_id, name, source_feed_id, format, template, filters)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: GetFeedMapping :one
SELECT * FROM feed_mappings
WHERE feed_id = $1 AND company_id = $2 AND is_active = true
ORDER BY version DESC
LIMIT 1;
-- name: UpsertFeedMapping :one
INSERT INTO feed_mappings (feed_id, company_id, version, mappings, is_active)
VALUES ($1, $2, $3, $4, true)
RETURNING *;
+18
View File
@@ -0,0 +1,18 @@
-- name: CreateInvite :one
INSERT INTO invites (company_id, email, role, token, invited_by, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: GetInviteByToken :one
SELECT * FROM invites WHERE token = $1 LIMIT 1;
-- name: ListInvitesByCompany :many
SELECT * FROM invites
WHERE company_id = $1 AND accepted_at IS NULL
ORDER BY created_at DESC;
-- name: AcceptInvite :one
UPDATE invites
SET accepted_at = now()
WHERE id = $1 AND accepted_at IS NULL
RETURNING *;
+29
View File
@@ -0,0 +1,29 @@
-- name: CreateMembership :one
INSERT INTO memberships (company_id, user_id, role, status)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetMembership :one
SELECT * FROM memberships
WHERE company_id = $1 AND user_id = $2
LIMIT 1;
-- name: ListMembershipsByCompany :many
SELECT m.*, u.email, u.name AS user_name
FROM memberships m
JOIN users u ON u.id = m.user_id
WHERE m.company_id = $1
ORDER BY m.created_at;
-- name: ListMembershipsByUser :many
SELECT m.*, c.name AS company_name
FROM memberships m
JOIN companies c ON c.id = m.company_id
WHERE m.user_id = $1 AND m.status = 'active'
ORDER BY m.created_at;
-- name: DeactivateMembership :one
UPDATE memberships
SET status = 'inactive', updated_at = now()
WHERE company_id = $1 AND user_id = $2
RETURNING *;
+62
View File
@@ -0,0 +1,62 @@
-- name: CreateProcessingJob :one
INSERT INTO processing_jobs (
company_id, user_id, status, total_products, processing_type, priority, estimated_tokens
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: GetProcessingJob :one
SELECT * FROM processing_jobs
WHERE id = $1 AND company_id = $2
LIMIT 1;
-- name: ListProcessingJobs :many
SELECT * FROM processing_jobs
WHERE company_id = $1
ORDER BY created_at DESC
LIMIT $2;
-- name: UpdateProcessingJobStatus :one
UPDATE processing_jobs
SET status = $2,
processed_products = COALESCE($3, processed_products),
error = COALESCE($4, error),
started_at = COALESCE($5, started_at),
completed_at = COALESCE($6, completed_at),
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: CancelProcessingJob :one
UPDATE processing_jobs
SET status = 'cancelled', completed_at = now(), updated_at = now()
WHERE id = $1 AND company_id = $2 AND status IN ('pending', 'running')
RETURNING *;
-- name: CreateProcessingJobProduct :one
INSERT INTO processing_job_products (job_id, raw_product_id, status)
VALUES ($1, $2, $3)
RETURNING *;
-- name: ListPendingJobProducts :many
SELECT * FROM processing_job_products
WHERE job_id = $1 AND status = 'pending'
ORDER BY created_at
LIMIT $2;
-- name: UpdateJobProductStatus :one
UPDATE processing_job_products
SET status = $2, error = $3, processed_product_id = $4, updated_at = now()
WHERE id = $1
RETURNING *;
-- name: ClaimNextPendingJob :one
UPDATE processing_jobs
SET status = 'running', started_at = now(), updated_at = now()
WHERE id = (
SELECT id FROM processing_jobs
WHERE status = 'pending'
ORDER BY priority DESC, created_at
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING *;
+48
View File
@@ -0,0 +1,48 @@
-- name: ListRawProducts :many
SELECT * FROM raw_products
WHERE company_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3;
-- name: CountRawProducts :one
SELECT count(*)::bigint FROM raw_products WHERE company_id = $1;
-- name: GetRawProduct :one
SELECT * FROM raw_products
WHERE id = $1 AND company_id = $2
LIMIT 1;
-- name: UpdateRawProductStatus :one
UPDATE raw_products
SET processing_status = $3, is_processed = $4, updated_at = now()
WHERE id = $1 AND company_id = $2
RETURNING *;
-- name: ListProcessedProducts :many
SELECT * FROM processed_products
WHERE company_id = $1
ORDER BY updated_at DESC
LIMIT $2 OFFSET $3;
-- name: GetProcessedProduct :one
SELECT * FROM processed_products
WHERE id = $1 AND company_id = $2
LIMIT 1;
-- name: UpdateProcessedProduct :one
UPDATE processed_products
SET name = COALESCE($3, name),
description = COALESCE($4, description),
processed_name = COALESCE($5, processed_name),
processed_description = COALESCE($6, processed_description),
category = COALESCE($7, category),
status = COALESCE($8, status),
updated_at = now()
WHERE id = $1 AND company_id = $2
RETURNING *;
-- name: CreateProcessedProduct :one
INSERT INTO processed_products (
company_id, user_id, raw_product_id, product_id, name, description, status, attributes
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
+22
View File
@@ -0,0 +1,22 @@
-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = $1 LIMIT 1;
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1 LIMIT 1;
-- name: GetUserByLegacyID :one
SELECT * FROM users WHERE legacy_user_id = $1 LIMIT 1;
-- name: CreateUser :one
INSERT INTO users (email, name, password_hash, must_set_password, legacy_user_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: UpdateUserPassword :one
UPDATE users
SET password_hash = $2, must_set_password = false, updated_at = now()
WHERE id = $1
RETURNING *;
-- name: TouchUserLogin :exec
UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = $1;
+167
View File
@@ -0,0 +1,167 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
language TEXT NOT NULL DEFAULT 'en',
merge_products_by_gtin BOOLEAN NOT NULL DEFAULT false,
legacy_company_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT,
password_hash TEXT,
must_set_password BOOLEAN NOT NULL DEFAULT true,
email_verified_at TIMESTAMPTZ,
is_platform_admin BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
legacy_user_id TEXT UNIQUE,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, user_id)
);
CREATE INDEX memberships_user_id_idx ON memberships(user_id);
CREATE INDEX memberships_company_id_idx ON memberships(company_id);
CREATE TABLE invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')),
token TEXT NOT NULL UNIQUE,
invited_by UUID REFERENCES users(id) ON DELETE SET NULL,
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX invites_company_email_idx ON invites(company_id, email);
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
data BYTEA NOT NULL,
expiry TIMESTAMPTZ NOT NULL
);
CREATE INDEX sessions_expiry_idx ON sessions(expiry);
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT,
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX api_keys_company_id_idx ON api_keys(company_id);
CREATE TABLE company_settings (
company_id UUID PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE,
settings JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE plans (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
monthly_credits INT NOT NULL,
yearly_credits INT,
max_products INT,
is_custom BOOLEAN NOT NULL DEFAULT false,
term TEXT NOT NULL DEFAULT 'monthly',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE company_plans (
id BIGSERIAL PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
plan_id BIGINT NOT NULL REFERENCES plans(id),
is_active BOOLEAN NOT NULL DEFAULT true,
billing_cycle_start TIMESTAMPTZ NOT NULL,
next_billing_date TIMESTAMPTZ NOT NULL,
contract_start_date TIMESTAMPTZ,
contract_end_date TIMESTAMPTZ,
custom_monthly_credits INT,
total_credits_allocated INT,
custom_max_products INT,
contract_reference TEXT,
notes TEXT,
is_trial BOOLEAN NOT NULL DEFAULT false,
trial_ends_at TIMESTAMPTZ,
trial_credits INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX company_plans_company_id_idx ON company_plans(company_id);
CREATE TABLE billing_cycles (
id BIGSERIAL PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
start_date TIMESTAMPTZ NOT NULL,
end_date TIMESTAMPTZ NOT NULL,
credits_used INT NOT NULL DEFAULT 0,
products_processed INT NOT NULL DEFAULT 0,
invoice_amount INT,
invoice_notes TEXT,
is_invoiced BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX billing_cycles_company_id_idx ON billing_cycles(company_id);
CREATE TABLE credit_balances (
company_id UUID PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE,
total_credits INT NOT NULL DEFAULT 0,
used_credits INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE processing_costs (
id BIGSERIAL PRIMARY KEY,
feature_name TEXT NOT NULL UNIQUE,
cost_per_unit INT NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS processing_costs;
DROP TABLE IF EXISTS credit_balances;
DROP TABLE IF EXISTS billing_cycles;
DROP TABLE IF EXISTS company_plans;
DROP TABLE IF EXISTS plans;
DROP TABLE IF EXISTS company_settings;
DROP TABLE IF EXISTS api_keys;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS invites;
DROP TABLE IF EXISTS memberships;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS companies;
+142
View File
@@ -0,0 +1,142 @@
-- +goose Up
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
unique_id TEXT NOT NULL,
parent_unique_id TEXT,
path TEXT,
level INT NOT NULL DEFAULT 0,
position INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
description TEXT,
prompt TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
title_template JSONB,
description_template JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, unique_id)
);
CREATE INDEX categories_company_id_idx ON categories(company_id);
CREATE TABLE attributes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
attribute_key TEXT NOT NULL,
name TEXT NOT NULL,
value_type TEXT NOT NULL DEFAULT 'string',
unit TEXT,
example TEXT,
parent_key TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, attribute_key)
);
CREATE INDEX attributes_company_id_idx ON attributes(company_id);
CREATE TABLE category_attributes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
category_unique_id TEXT NOT NULL,
attribute_id UUID NOT NULL REFERENCES attributes(id) ON DELETE CASCADE,
required BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, category_unique_id, attribute_id)
);
CREATE INDEX category_attributes_company_id_idx ON category_attributes(company_id);
CREATE TABLE custom_variables (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, name)
);
CREATE INDEX custom_variables_company_id_idx ON custom_variables(company_id);
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
name TEXT NOT NULL,
path TEXT,
content_type TEXT,
size_bytes BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'uploaded',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX files_company_id_idx ON files(company_id);
CREATE TABLE raw_products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
gtin TEXT NOT NULL,
feed_id UUID,
feed_ids JSONB,
raw_data JSONB NOT NULL DEFAULT '{}'::jsonb,
mapped_data JSONB NOT NULL DEFAULT '{}'::jsonb,
sync_job_id UUID,
is_processed BOOLEAN NOT NULL DEFAULT false,
processing_status TEXT NOT NULL DEFAULT 'unprocessed'
CHECK (processing_status IN ('unprocessed', 'processing', 'processed', 'failed')),
file_id UUID REFERENCES files(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX raw_products_company_id_idx ON raw_products(company_id);
CREATE INDEX raw_products_gtin_idx ON raw_products(gtin);
CREATE INDEX raw_products_feed_id_idx ON raw_products(feed_id);
CREATE INDEX raw_products_processing_status_idx ON raw_products(processing_status);
CREATE TABLE processed_products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
product_id TEXT,
name TEXT,
category TEXT,
description TEXT,
processed_description TEXT,
attributes JSONB,
processed_attributes JSONB,
status TEXT,
gpt_response JSONB,
total_tokens INT,
feed_id UUID,
raw_product_id UUID REFERENCES raw_products(id) ON DELETE SET NULL,
processed_name TEXT,
meta_title TEXT,
meta_description TEXT,
last_transition_at TIMESTAMPTZ,
structured_description JSONB,
field_sources JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX processed_products_company_id_idx ON processed_products(company_id);
CREATE INDEX processed_products_raw_product_id_idx ON processed_products(raw_product_id);
CREATE INDEX processed_products_product_id_idx ON processed_products(product_id);
-- +goose Down
DROP TABLE IF EXISTS processed_products;
DROP TABLE IF EXISTS raw_products;
DROP TABLE IF EXISTS files;
DROP TABLE IF EXISTS custom_variables;
DROP TABLE IF EXISTS category_attributes;
DROP TABLE IF EXISTS attributes;
DROP TABLE IF EXISTS categories;
+117
View File
@@ -0,0 +1,117 @@
-- +goose Up
CREATE TABLE input_feeds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
url TEXT,
feed_type TEXT NOT NULL DEFAULT 'xml',
status TEXT NOT NULL DEFAULT 'active',
sync_interval_minutes INT NOT NULL DEFAULT 60,
last_synced_at TIMESTAMPTZ,
auth_config JSONB NOT NULL DEFAULT '{}'::jsonb,
options JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX input_feeds_company_id_idx ON input_feeds(company_id);
ALTER TABLE raw_products
ADD CONSTRAINT raw_products_feed_id_fkey
FOREIGN KEY (feed_id) REFERENCES input_feeds(id) ON DELETE SET NULL;
ALTER TABLE processed_products
ADD CONSTRAINT processed_products_feed_id_fkey
FOREIGN KEY (feed_id) REFERENCES input_feeds(id) ON DELETE SET NULL;
CREATE TABLE feed_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
feed_id UUID NOT NULL REFERENCES input_feeds(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
version INT NOT NULL DEFAULT 1,
mappings JSONB NOT NULL DEFAULT '[]'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX feed_mappings_feed_id_idx ON feed_mappings(feed_id);
CREATE TABLE feed_sync_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
feed_id UUID NOT NULL REFERENCES input_feeds(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
products_synced INT NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX feed_sync_jobs_feed_id_idx ON feed_sync_jobs(feed_id);
CREATE INDEX feed_sync_jobs_company_id_idx ON feed_sync_jobs(company_id);
ALTER TABLE raw_products
ADD CONSTRAINT raw_products_sync_job_id_fkey
FOREIGN KEY (sync_job_id) REFERENCES feed_sync_jobs(id) ON DELETE SET NULL;
CREATE TABLE export_feeds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
source_feed_id UUID REFERENCES input_feeds(id) ON DELETE SET NULL,
format TEXT NOT NULL DEFAULT 'xml',
public_token TEXT NOT NULL UNIQUE DEFAULT encode(gen_random_bytes(16), 'hex'),
template JSONB NOT NULL DEFAULT '{}'::jsonb,
filters JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT true,
last_generated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX export_feeds_company_id_idx ON export_feeds(company_id);
CREATE INDEX export_feeds_public_token_idx ON export_feeds(public_token);
CREATE TABLE feed_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#888888',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, name)
);
CREATE TABLE feed_tag_mappings (
feed_id UUID NOT NULL REFERENCES input_feeds(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES feed_tags(id) ON DELETE CASCADE,
PRIMARY KEY (feed_id, tag_id)
);
CREATE TABLE schema_extraction_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
feed_id UUID NOT NULL REFERENCES input_feeds(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
progress INT NOT NULL DEFAULT 0,
error TEXT,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX schema_extraction_tasks_feed_id_idx ON schema_extraction_tasks(feed_id);
-- +goose Down
DROP TABLE IF EXISTS schema_extraction_tasks;
DROP TABLE IF EXISTS feed_tag_mappings;
DROP TABLE IF EXISTS feed_tags;
DROP TABLE IF EXISTS export_feeds;
ALTER TABLE raw_products DROP CONSTRAINT IF EXISTS raw_products_sync_job_id_fkey;
DROP TABLE IF EXISTS feed_sync_jobs;
DROP TABLE IF EXISTS feed_mappings;
ALTER TABLE processed_products DROP CONSTRAINT IF EXISTS processed_products_feed_id_fkey;
ALTER TABLE raw_products DROP CONSTRAINT IF EXISTS raw_products_feed_id_fkey;
DROP TABLE IF EXISTS input_feeds;
+60
View File
@@ -0,0 +1,60 @@
-- +goose Up
CREATE TABLE processing_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
total_products INT NOT NULL DEFAULT 0,
processed_products INT NOT NULL DEFAULT 0,
error TEXT,
processing_type TEXT NOT NULL DEFAULT 'full',
priority INT NOT NULL DEFAULT 0,
estimated_tokens INT NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX processing_jobs_company_id_idx ON processing_jobs(company_id);
CREATE INDEX processing_jobs_status_idx ON processing_jobs(status);
CREATE TABLE processing_job_products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES processing_jobs(id) ON DELETE CASCADE,
raw_product_id UUID NOT NULL REFERENCES raw_products(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'processed', 'failed', 'cancelled')),
error TEXT,
processed_product_id UUID REFERENCES processed_products(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX processing_job_products_job_id_idx ON processing_job_products(job_id);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
task_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
start_time TIMESTAMPTZ DEFAULT now(),
end_time TIMESTAMPTZ,
log TEXT,
processing_products INT NOT NULL DEFAULT 0,
processed_products INT NOT NULL DEFAULT 0,
total_products INT NOT NULL DEFAULT 0,
error_products JSONB,
product_ids JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX tasks_company_id_idx ON tasks(company_id);
-- +goose Down
DROP TABLE IF EXISTS tasks;
DROP TABLE IF EXISTS processing_job_products;
DROP TABLE IF EXISTS processing_jobs;
+17
View File
@@ -0,0 +1,17 @@
-- +goose Up
CREATE TABLE woocommerce_configs (
company_id UUID PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE,
store_url TEXT NOT NULL DEFAULT '',
consumer_key TEXT NOT NULL DEFAULT '',
consumer_secret TEXT NOT NULL DEFAULT '',
is_enabled BOOLEAN NOT NULL DEFAULT false,
sync_options JSONB NOT NULL DEFAULT '{}'::jsonb,
last_synced_at TIMESTAMPTZ,
last_test_at TIMESTAMPTZ,
last_test_status TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS woocommerce_configs;
+19
View File
@@ -0,0 +1,19 @@
-- +goose Up
ALTER TABLE feed_sync_jobs
ADD COLUMN IF NOT EXISTS products_total INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS products_skipped INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS products_unchanged INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS progress INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS content_hash TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS raw_products_company_gtin_uidx
ON raw_products (company_id, gtin);
-- +goose Down
DROP INDEX IF EXISTS raw_products_company_gtin_uidx;
ALTER TABLE feed_sync_jobs
DROP COLUMN IF EXISTS content_hash,
DROP COLUMN IF EXISTS progress,
DROP COLUMN IF EXISTS products_unchanged,
DROP COLUMN IF EXISTS products_skipped,
DROP COLUMN IF EXISTS products_total;
@@ -0,0 +1,50 @@
-- +goose Up
CREATE TABLE field_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
"order" INT NOT NULL DEFAULT 0,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX field_groups_company_id_idx ON field_groups(company_id);
CREATE TABLE standard_fields (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key TEXT NOT NULL,
type TEXT NOT NULL,
group_id UUID NOT NULL REFERENCES field_groups(id) ON DELETE CASCADE,
is_required BOOLEAN NOT NULL DEFAULT false,
description TEXT,
default_value TEXT,
validation JSONB NOT NULL DEFAULT '{}'::jsonb,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, key)
);
CREATE INDEX standard_fields_company_id_idx ON standard_fields(company_id);
CREATE INDEX standard_fields_group_id_idx ON standard_fields(group_id);
CREATE TABLE structured_description_fields (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
field_key TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'text',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, field_key)
);
CREATE INDEX structured_description_fields_company_id_idx ON structured_description_fields(company_id);
-- +goose Down
DROP TABLE IF EXISTS standard_fields;
DROP TABLE IF EXISTS structured_description_fields;
DROP TABLE IF EXISTS field_groups;
@@ -0,0 +1,17 @@
-- +goose Up
ALTER TABLE standard_fields
ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS unit TEXT,
ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS mapping_hints JSONB NOT NULL DEFAULT '[]'::jsonb;
CREATE INDEX IF NOT EXISTS standard_fields_company_enabled_idx
ON standard_fields(company_id, enabled);
-- +goose Down
DROP INDEX IF EXISTS standard_fields_company_enabled_idx;
ALTER TABLE standard_fields
DROP COLUMN IF EXISTS mapping_hints,
DROP COLUMN IF EXISTS sort_order,
DROP COLUMN IF EXISTS unit,
DROP COLUMN IF EXISTS enabled;
@@ -0,0 +1,9 @@
-- +goose Up
ALTER TABLE processing_jobs
ADD COLUMN IF NOT EXISTS current_step TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS step_progress JSONB NOT NULL DEFAULT '[]'::jsonb;
-- +goose Down
ALTER TABLE processing_jobs
DROP COLUMN IF EXISTS step_progress,
DROP COLUMN IF EXISTS current_step;
@@ -0,0 +1,73 @@
-- +goose Up
CREATE TABLE woo_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
external_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT '',
total NUMERIC(14, 2),
customer_id BIGINT,
customer_email TEXT,
customer_name TEXT,
ordered_at TIMESTAMPTZ,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, external_id)
);
CREATE INDEX woo_orders_company_email_idx ON woo_orders (company_id, lower(customer_email));
CREATE INDEX woo_orders_company_status_idx ON woo_orders (company_id, status);
CREATE INDEX woo_orders_company_ordered_idx ON woo_orders (company_id, ordered_at DESC);
CREATE TABLE woo_order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
order_id UUID NOT NULL REFERENCES woo_orders(id) ON DELETE CASCADE,
external_id BIGINT NOT NULL,
product_id BIGINT,
variation_id BIGINT,
sku TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
quantity INT NOT NULL DEFAULT 1,
total NUMERIC(14, 2),
categories JSONB NOT NULL DEFAULT '[]'::jsonb,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, order_id, external_id)
);
CREATE INDEX woo_order_items_company_product_idx ON woo_order_items (company_id, product_id);
CREATE INDEX woo_order_items_company_sku_idx ON woo_order_items (company_id, sku) WHERE sku <> '';
CREATE INDEX woo_order_items_categories_gin ON woo_order_items USING GIN (categories);
CREATE TABLE product_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
external_id BIGINT NOT NULL,
product_id BIGINT,
product_name TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
reviewer TEXT NOT NULL DEFAULT '',
reviewer_email TEXT NOT NULL DEFAULT '',
rating INT,
review TEXT NOT NULL DEFAULT '',
reviewed_at TIMESTAMPTZ,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, external_id)
);
CREATE INDEX product_reviews_company_product_idx ON product_reviews (company_id, product_id);
CREATE INDEX product_reviews_company_rating_idx ON product_reviews (company_id, rating);
CREATE INDEX product_reviews_company_status_idx ON product_reviews (company_id, status);
CREATE INDEX product_reviews_company_reviewed_idx ON product_reviews (company_id, reviewed_at DESC);
-- +goose Down
DROP TABLE IF EXISTS woo_order_items;
DROP TABLE IF EXISTS product_reviews;
DROP TABLE IF EXISTS woo_orders;
@@ -0,0 +1,47 @@
-- +goose Up
-- Email campaign drafts + generated versions (Marketing suite P0).
-- Provider / unsubscribe / send log tables live in 012_integrations_email.sql.
CREATE TABLE email_campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
template_key TEXT NOT NULL DEFAULT 'custom'
CHECK (template_key IN ('christmas', 'black_friday', 'spring', 'custom')),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'ready', 'scheduled', 'sent', 'cancelled')),
category_ids UUID[] NOT NULL DEFAULT '{}',
product_ids UUID[] NOT NULL DEFAULT '{}',
prompt TEXT NOT NULL DEFAULT '',
use_default_prompt BOOLEAN NOT NULL DEFAULT true,
audience_filter JSONB NOT NULL DEFAULT '{}'::jsonb,
scheduled_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX email_campaigns_company_id_idx ON email_campaigns (company_id);
CREATE INDEX email_campaigns_company_status_idx ON email_campaigns (company_id, status);
CREATE TABLE email_campaign_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID NOT NULL REFERENCES email_campaigns(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
version INT NOT NULL DEFAULT 1,
subject TEXT NOT NULL DEFAULT '',
html_body TEXT NOT NULL DEFAULT '',
plain_body TEXT NOT NULL DEFAULT '',
generation_mode TEXT NOT NULL DEFAULT 'template'
CHECK (generation_mode IN ('template', 'ai')),
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (campaign_id, version)
);
CREATE INDEX email_campaign_versions_campaign_idx ON email_campaign_versions (campaign_id, version DESC);
-- +goose Down
DROP TABLE IF EXISTS email_campaign_versions;
DROP TABLE IF EXISTS email_campaigns;
@@ -0,0 +1,67 @@
-- +goose Up
-- Tenant email provider + unsubscribes + send log (Marketing suite).
-- Secrets: AES-GCM ciphertext (enc:v1:...) of JSON {api_key,smtp_*}; never log plaintext.
-- Prefer APP_ENCRYPTION_KEY for DeriveKey.
CREATE TABLE email_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
provider_type TEXT NOT NULL DEFAULT 'smtp'
CHECK (provider_type IN ('smtp', 'resend', 'sendgrid')),
from_email TEXT NOT NULL DEFAULT '',
from_name TEXT NOT NULL DEFAULT '',
secrets_enc TEXT NOT NULL DEFAULT '',
config JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'unverified'
CHECK (status IN ('unverified', 'verified', 'error')),
verified_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id)
);
CREATE INDEX email_providers_company_status_idx ON email_providers (company_id, status);
CREATE TABLE email_unsubscribes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
email TEXT NOT NULL,
email_hash TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
reason TEXT,
unsubscribed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, email_hash)
);
CREATE INDEX email_unsubscribes_company_idx ON email_unsubscribes (company_id);
CREATE TABLE email_sends (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE SET NULL,
version_id UUID REFERENCES email_campaign_versions(id) ON DELETE SET NULL,
recipient_email TEXT NOT NULL,
recipient_hash TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'blast'
CHECK (kind IN ('test', 'blast')),
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'sent', 'failed', 'skipped', 'unsubscribed')),
provider_message_id TEXT,
error TEXT,
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX email_sends_company_created_idx ON email_sends (company_id, created_at DESC);
CREATE INDEX email_sends_campaign_idx ON email_sends (campaign_id);
CREATE UNIQUE INDEX email_sends_blast_idempotent_idx
ON email_sends (campaign_id, recipient_hash)
WHERE kind = 'blast' AND status IN ('queued', 'sent');
-- +goose Down
DROP TABLE IF EXISTS email_sends;
DROP TABLE IF EXISTS email_unsubscribes;
DROP TABLE IF EXISTS email_providers;
+15
View File
@@ -0,0 +1,15 @@
-- +goose Up
CREATE TABLE company_brand (
company_id UUID PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE,
voice_tone TEXT NOT NULL DEFAULT '',
dos TEXT[] NOT NULL DEFAULT '{}',
donts TEXT[] NOT NULL DEFAULT '{}',
primary_color TEXT NOT NULL DEFAULT '',
secondary_color TEXT NOT NULL DEFAULT '',
logo_url TEXT NOT NULL DEFAULT '',
preferred_terms TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS company_brand;
@@ -0,0 +1,9 @@
-- +goose Up
-- Allow pending unsubscribe tokens (issued at send time) before the recipient opts out.
ALTER TABLE email_unsubscribes ALTER COLUMN unsubscribed_at DROP NOT NULL;
ALTER TABLE email_unsubscribes ALTER COLUMN unsubscribed_at DROP DEFAULT;
-- +goose Down
UPDATE email_unsubscribes SET unsubscribed_at = COALESCE(unsubscribed_at, now()) WHERE unsubscribed_at IS NULL;
ALTER TABLE email_unsubscribes ALTER COLUMN unsubscribed_at SET DEFAULT now();
ALTER TABLE email_unsubscribes ALTER COLUMN unsubscribed_at SET NOT NULL;
+47
View File
@@ -0,0 +1,47 @@
-- +goose Up
-- Tenant AI / BYOK providers (OpenAI-compatible). Secrets: AES-GCM enc:v1:; never return plaintext.
-- Analytics field (coordinate): ai_provider_mode on jobs/products =
-- 'internal' | 'popular:<name>' | 'custom'
CREATE TABLE ai_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
mode TEXT NOT NULL DEFAULT 'internal'
CHECK (mode IN ('internal', 'popular', 'custom')),
popular_name TEXT NOT NULL DEFAULT '',
base_url TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
api_key_enc TEXT NOT NULL DEFAULT '',
api_key_last4 TEXT NOT NULL DEFAULT '',
is_enabled BOOLEAN NOT NULL DEFAULT false,
last_test_at TIMESTAMPTZ,
last_test_status TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id)
);
CREATE INDEX ai_providers_company_enabled_idx ON ai_providers (company_id, is_enabled);
ALTER TABLE processing_jobs
ADD COLUMN IF NOT EXISTS ai_provider_mode TEXT NOT NULL DEFAULT 'internal';
ALTER TABLE processed_products
ADD COLUMN IF NOT EXISTS ai_provider_mode TEXT NOT NULL DEFAULT 'internal';
CREATE INDEX IF NOT EXISTS processed_products_ai_provider_mode_idx
ON processed_products (ai_provider_mode);
CREATE INDEX IF NOT EXISTS processed_products_company_ai_provider_mode_idx
ON processed_products (company_id, ai_provider_mode);
CREATE INDEX IF NOT EXISTS processing_jobs_ai_provider_mode_idx
ON processing_jobs (ai_provider_mode);
-- +goose Down
DROP INDEX IF EXISTS processing_jobs_ai_provider_mode_idx;
DROP INDEX IF EXISTS processed_products_company_ai_provider_mode_idx;
DROP INDEX IF EXISTS processed_products_ai_provider_mode_idx;
ALTER TABLE processed_products DROP COLUMN IF EXISTS ai_provider_mode;
ALTER TABLE processing_jobs DROP COLUMN IF EXISTS ai_provider_mode;
DROP TABLE IF EXISTS ai_providers;
@@ -0,0 +1,32 @@
-- +goose Up
-- Stripe customer / subscription linkage + webhook idempotency (company-scoped).
ALTER TABLE companies
ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS companies_stripe_customer_id_uidx
ON companies (stripe_customer_id)
WHERE stripe_customer_id IS NOT NULL AND stripe_customer_id <> '';
ALTER TABLE company_plans
ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT,
ADD COLUMN IF NOT EXISTS stripe_price_id TEXT;
CREATE INDEX IF NOT EXISTS company_plans_stripe_subscription_id_idx
ON company_plans (stripe_subscription_id)
WHERE stripe_subscription_id IS NOT NULL AND stripe_subscription_id <> '';
CREATE TABLE IF NOT EXISTS stripe_webhook_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
company_id UUID REFERENCES companies(id) ON DELETE SET NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS stripe_webhook_events;
DROP INDEX IF EXISTS company_plans_stripe_subscription_id_idx;
ALTER TABLE company_plans DROP COLUMN IF EXISTS stripe_price_id;
ALTER TABLE company_plans DROP COLUMN IF EXISTS stripe_subscription_id;
DROP INDEX IF EXISTS companies_stripe_customer_id_uidx;
ALTER TABLE companies DROP COLUMN IF EXISTS stripe_customer_id;
+61
View File
@@ -0,0 +1,61 @@
-- +goose Up
CREATE TABLE shopify_configs (
company_id UUID PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE,
shop_domain TEXT NOT NULL DEFAULT '',
access_token TEXT NOT NULL DEFAULT '',
api_version TEXT NOT NULL DEFAULT '2024-10',
is_enabled BOOLEAN NOT NULL DEFAULT false,
sync_options JSONB NOT NULL DEFAULT '{}'::jsonb,
last_synced_at TIMESTAMPTZ,
last_test_at TIMESTAMPTZ,
last_test_status TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE shopify_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
external_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT '',
total NUMERIC(14, 2),
customer_id BIGINT,
customer_email TEXT,
customer_name TEXT,
ordered_at TIMESTAMPTZ,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, external_id)
);
CREATE INDEX shopify_orders_company_email_idx ON shopify_orders (company_id, lower(customer_email));
CREATE INDEX shopify_orders_company_status_idx ON shopify_orders (company_id, status);
CREATE INDEX shopify_orders_company_ordered_idx ON shopify_orders (company_id, ordered_at DESC);
CREATE TABLE shopify_order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
order_id UUID NOT NULL REFERENCES shopify_orders(id) ON DELETE CASCADE,
external_id BIGINT NOT NULL,
product_id BIGINT,
variant_id BIGINT,
sku TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
quantity INT NOT NULL DEFAULT 1,
total NUMERIC(14, 2),
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, order_id, external_id)
);
CREATE INDEX shopify_order_items_company_product_idx ON shopify_order_items (company_id, product_id);
CREATE INDEX shopify_order_items_company_sku_idx ON shopify_order_items (company_id, sku) WHERE sku <> '';
-- +goose Down
DROP TABLE IF EXISTS shopify_order_items;
DROP TABLE IF EXISTS shopify_orders;
DROP TABLE IF EXISTS shopify_configs;
@@ -0,0 +1,27 @@
-- +goose Up
-- Hot-path indexes for product lists and category tree (Local Demo Co / large catalogs).
-- processed list: WHERE company_id=? [AND status=?] ORDER BY updated_at
CREATE INDEX IF NOT EXISTS processed_products_company_updated_idx
ON processed_products (company_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS processed_products_company_status_updated_idx
ON processed_products (company_id, status, updated_at DESC);
-- raw list: WHERE company_id=? [AND is_processed=?] ORDER BY updated_at
CREATE INDEX IF NOT EXISTS raw_products_company_updated_idx
ON raw_products (company_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS raw_products_company_processed_updated_idx
ON raw_products (company_id, is_processed, updated_at DESC);
-- category tree: WHERE company_id=? AND parent_unique_id IS NULL / = ?
CREATE INDEX IF NOT EXISTS categories_company_parent_idx
ON categories (company_id, parent_unique_id);
-- +goose Down
DROP INDEX IF EXISTS categories_company_parent_idx;
DROP INDEX IF EXISTS raw_products_company_processed_updated_idx;
DROP INDEX IF EXISTS raw_products_company_updated_idx;
DROP INDEX IF EXISTS processed_products_company_status_updated_idx;
DROP INDEX IF EXISTS processed_products_company_updated_idx;
@@ -0,0 +1,53 @@
-- +goose Up
-- Enforce one processed row per (company_id, raw_product_id) so processOne can
-- UPSERT with ON CONFLICT instead of a racy check-then-insert.
-- raw_product_id is nullable (ON DELETE SET NULL); PostgreSQL UNIQUE treats NULLs
-- as distinct, so orphan rows with NULL raw_product_id remain allowed.
-- Re-point job items at the keeper before deleting duplicate processed rows.
WITH ranked AS (
SELECT
id,
company_id,
raw_product_id,
ROW_NUMBER() OVER (
PARTITION BY company_id, raw_product_id
ORDER BY updated_at DESC NULLS LAST, created_at DESC NULLS LAST, id DESC
) AS rn
FROM processed_products
WHERE raw_product_id IS NOT NULL
),
keepers AS (
SELECT id, company_id, raw_product_id FROM ranked WHERE rn = 1
),
dupes AS (
SELECT id, company_id, raw_product_id FROM ranked WHERE rn > 1
)
UPDATE processing_job_products pjp
SET processed_product_id = k.id
FROM dupes d
JOIN keepers k
ON k.company_id = d.company_id
AND k.raw_product_id = d.raw_product_id
WHERE pjp.processed_product_id = d.id;
DELETE FROM processed_products
WHERE id IN (
SELECT id FROM (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY company_id, raw_product_id
ORDER BY updated_at DESC NULLS LAST, created_at DESC NULLS LAST, id DESC
) AS rn
FROM processed_products
WHERE raw_product_id IS NOT NULL
) d
WHERE rn > 1
);
CREATE UNIQUE INDEX IF NOT EXISTS processed_products_company_raw_uidx
ON processed_products (company_id, raw_product_id);
-- +goose Down
DROP INDEX IF EXISTS processed_products_company_raw_uidx;
@@ -0,0 +1,18 @@
-- +goose Up
-- Hot-path indexes for default raw product list (ORDER BY created_at) and common filters.
-- EXPLAIN on Descrybe company (~85k raw): default list ~359ms (company_id scan + sort);
-- ORDER BY updated_at already ~0.15ms via raw_products_company_updated_idx (018).
CREATE INDEX IF NOT EXISTS raw_products_company_created_idx
ON raw_products (company_id, created_at DESC);
CREATE INDEX IF NOT EXISTS raw_products_company_status_created_idx
ON raw_products (company_id, processing_status, created_at DESC);
CREATE INDEX IF NOT EXISTS raw_products_company_feed_created_idx
ON raw_products (company_id, feed_id, created_at DESC);
-- +goose Down
DROP INDEX IF EXISTS raw_products_company_feed_created_idx;
DROP INDEX IF EXISTS raw_products_company_status_created_idx;
DROP INDEX IF EXISTS raw_products_company_created_idx;
@@ -0,0 +1,25 @@
-- +goose Up
-- Complements 018 (updated_at default sort) and 020 (created_at sort):
-- filter composites for status/feed on updated_at, plus processed feed/category.
-- raw list: WHERE company_id=? AND processing_status=? ORDER BY updated_at
CREATE INDEX IF NOT EXISTS raw_products_company_status_updated_idx
ON raw_products (company_id, processing_status, updated_at DESC);
-- raw list: WHERE company_id=? AND feed_id=? ORDER BY updated_at
CREATE INDEX IF NOT EXISTS raw_products_company_feed_updated_idx
ON raw_products (company_id, feed_id, updated_at DESC);
-- processed list: WHERE company_id=? AND feed_id=? ORDER BY updated_at
CREATE INDEX IF NOT EXISTS processed_products_company_feed_updated_idx
ON processed_products (company_id, feed_id, updated_at DESC);
-- processed list: WHERE company_id=? AND category=? ORDER BY updated_at
CREATE INDEX IF NOT EXISTS processed_products_company_category_updated_idx
ON processed_products (company_id, category, updated_at DESC);
-- +goose Down
DROP INDEX IF EXISTS processed_products_company_category_updated_idx;
DROP INDEX IF EXISTS processed_products_company_feed_updated_idx;
DROP INDEX IF EXISTS raw_products_company_feed_updated_idx;
DROP INDEX IF EXISTS raw_products_company_status_updated_idx;
@@ -0,0 +1,19 @@
-- +goose Up
-- Export/list keyset: WHERE company_id=? AND status=? ORDER BY updated_at DESC, id DESC.
-- Replaces processed_products_company_status_updated_idx from 018 (no id) - suboptimal for
-- (updated_at, id) keyset seeks used by export feed generation (queryExportProductsBatch).
-- Left-prefix still covers list ORDER BY updated_at without id.
DROP INDEX IF EXISTS processed_products_company_status_updated_idx;
CREATE INDEX IF NOT EXISTS processed_products_company_status_updated_idx
ON processed_products (company_id, status, updated_at DESC, id DESC);
-- +goose Down
-- Restore the pre-022 index shape from 018 (without id). Needed so goose down is reversible
-- and list hot-path still has a status+updated_at composite after rollback.
DROP INDEX IF EXISTS processed_products_company_status_updated_idx;
CREATE INDEX IF NOT EXISTS processed_products_company_status_updated_idx
ON processed_products (company_id, status, updated_at DESC);
@@ -0,0 +1,97 @@
-- +goose Up
-- List keyset: ORDER BY created_at|updated_at, id (see catalog rawProductsOrderBy /
-- processedProductsOrderBy + appendRawKeyset / appendProcessedKeyset).
-- Extends 018/020/021 composites with trailing id; does not touch 022
-- (processed_products_company_status_updated_idx already has id).
-- raw: company + updated_at (018)
DROP INDEX IF EXISTS raw_products_company_updated_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_updated_idx
ON raw_products (company_id, updated_at DESC, id DESC);
DROP INDEX IF EXISTS raw_products_company_processed_updated_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_processed_updated_idx
ON raw_products (company_id, is_processed, updated_at DESC, id DESC);
-- raw: company + created_at (020)
DROP INDEX IF EXISTS raw_products_company_created_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_created_idx
ON raw_products (company_id, created_at DESC, id DESC);
DROP INDEX IF EXISTS raw_products_company_status_created_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_status_created_idx
ON raw_products (company_id, processing_status, created_at DESC, id DESC);
DROP INDEX IF EXISTS raw_products_company_feed_created_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_feed_created_idx
ON raw_products (company_id, feed_id, created_at DESC, id DESC);
-- raw: filter + updated_at (021)
DROP INDEX IF EXISTS raw_products_company_status_updated_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_status_updated_idx
ON raw_products (company_id, processing_status, updated_at DESC, id DESC);
DROP INDEX IF EXISTS raw_products_company_feed_updated_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_feed_updated_idx
ON raw_products (company_id, feed_id, updated_at DESC, id DESC);
-- processed: company + updated_at (018); status+updated_at+id already in 022
DROP INDEX IF EXISTS processed_products_company_updated_idx;
CREATE INDEX IF NOT EXISTS processed_products_company_updated_idx
ON processed_products (company_id, updated_at DESC, id DESC);
-- processed: filter + updated_at (021)
DROP INDEX IF EXISTS processed_products_company_feed_updated_idx;
CREATE INDEX IF NOT EXISTS processed_products_company_feed_updated_idx
ON processed_products (company_id, feed_id, updated_at DESC, id DESC);
DROP INDEX IF EXISTS processed_products_company_category_updated_idx;
CREATE INDEX IF NOT EXISTS processed_products_company_category_updated_idx
ON processed_products (company_id, category, updated_at DESC, id DESC);
-- processed: createdAt keyset (no prior created_at list index)
CREATE INDEX IF NOT EXISTS processed_products_company_created_idx
ON processed_products (company_id, created_at DESC, id DESC);
-- +goose Down
DROP INDEX IF EXISTS processed_products_company_created_idx;
DROP INDEX IF EXISTS processed_products_company_category_updated_idx;
CREATE INDEX IF NOT EXISTS processed_products_company_category_updated_idx
ON processed_products (company_id, category, updated_at DESC);
DROP INDEX IF EXISTS processed_products_company_feed_updated_idx;
CREATE INDEX IF NOT EXISTS processed_products_company_feed_updated_idx
ON processed_products (company_id, feed_id, updated_at DESC);
DROP INDEX IF EXISTS processed_products_company_updated_idx;
CREATE INDEX IF NOT EXISTS processed_products_company_updated_idx
ON processed_products (company_id, updated_at DESC);
DROP INDEX IF EXISTS raw_products_company_feed_updated_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_feed_updated_idx
ON raw_products (company_id, feed_id, updated_at DESC);
DROP INDEX IF EXISTS raw_products_company_status_updated_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_status_updated_idx
ON raw_products (company_id, processing_status, updated_at DESC);
DROP INDEX IF EXISTS raw_products_company_feed_created_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_feed_created_idx
ON raw_products (company_id, feed_id, created_at DESC);
DROP INDEX IF EXISTS raw_products_company_status_created_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_status_created_idx
ON raw_products (company_id, processing_status, created_at DESC);
DROP INDEX IF EXISTS raw_products_company_created_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_created_idx
ON raw_products (company_id, created_at DESC);
DROP INDEX IF EXISTS raw_products_company_processed_updated_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_processed_updated_idx
ON raw_products (company_id, is_processed, updated_at DESC);
DROP INDEX IF EXISTS raw_products_company_updated_idx;
CREATE INDEX IF NOT EXISTS raw_products_company_updated_idx
ON raw_products (company_id, updated_at DESC);
+22
View File
@@ -0,0 +1,22 @@
-- +goose Up
-- Per-company editable AI prompt templates (system + user) with {{variable}} placeholders.
-- Empty templates fall back to built-in defaults in apps/api/internal/aiprompts.
CREATE TABLE ai_prompt_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
prompt_key TEXT NOT NULL
CHECK (prompt_key IN ('product_enhance', 'seo_meta', 'campaign_email')),
system_template TEXT NOT NULL DEFAULT '',
user_template TEXT NOT NULL DEFAULT '',
is_enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, prompt_key)
);
CREATE INDEX ai_prompt_templates_company_idx ON ai_prompt_templates (company_id);
-- +goose Down
DROP INDEX IF EXISTS ai_prompt_templates_company_idx;
DROP TABLE IF EXISTS ai_prompt_templates;
@@ -0,0 +1,72 @@
-- +goose Up
-- Internal support center: tickets, threaded messages, in-app notifications.
CREATE TABLE support_tickets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
created_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'other'
CHECK (category IN ('billing', 'bug', 'account', 'other')),
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'pending', 'resolved', 'closed')),
priority TEXT NOT NULL DEFAULT 'normal'
CHECK (priority IN ('low', 'normal', 'high')),
assignee_admin_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
last_message_at TIMESTAMPTZ,
last_customer_message_at TIMESTAMPTZ,
last_agent_message_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX support_tickets_admin_queue_idx
ON support_tickets (status, last_message_at DESC NULLS LAST);
CREATE INDEX support_tickets_company_user_idx
ON support_tickets (company_id, created_by_user_id, updated_at DESC);
CREATE INDEX support_tickets_company_status_idx
ON support_tickets (company_id, status);
CREATE TABLE support_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
author_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
author_role TEXT NOT NULL
CHECK (author_role IN ('user', 'agent', 'system')),
body TEXT NOT NULL,
is_internal_note BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX support_messages_ticket_idx
ON support_messages (ticket_id, created_at ASC);
CREATE TABLE support_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
message_id UUID REFERENCES support_messages(id) ON DELETE SET NULL,
kind TEXT NOT NULL
CHECK (kind IN ('ticket_created', 'agent_reply', 'status_changed', 'user_reply')),
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX support_notifications_user_unread_idx
ON support_notifications (user_id, read_at, created_at DESC);
CREATE INDEX support_notifications_user_created_idx
ON support_notifications (user_id, created_at DESC);
-- +goose Down
DROP INDEX IF EXISTS support_notifications_user_created_idx;
DROP INDEX IF EXISTS support_notifications_user_unread_idx;
DROP TABLE IF EXISTS support_notifications;
DROP INDEX IF EXISTS support_messages_ticket_idx;
DROP TABLE IF EXISTS support_messages;
DROP INDEX IF EXISTS support_tickets_company_status_idx;
DROP INDEX IF EXISTS support_tickets_company_user_idx;
DROP INDEX IF EXISTS support_tickets_admin_queue_idx;
DROP TABLE IF EXISTS support_tickets;
+21
View File
@@ -0,0 +1,21 @@
-- +goose Up
-- Plan dashboard feature permissions (per-plan overrides + global section/feature gates).
ALTER TABLE plans
ADD COLUMN IF NOT EXISTS features JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE TABLE IF NOT EXISTS platform_feature_gates (
gate_key TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('section', 'feature')),
enabled BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by UUID NULL REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS platform_feature_gates_kind_idx
ON platform_feature_gates (kind);
-- +goose Down
DROP INDEX IF EXISTS platform_feature_gates_kind_idx;
DROP TABLE IF EXISTS platform_feature_gates;
ALTER TABLE plans DROP COLUMN IF EXISTS features;
@@ -0,0 +1,27 @@
-- +goose Up
-- Hot-path indexes for capabilities resolution + support queues (admin / staff / user).
-- Active plan lookup: CapabilitiesForCompany / CreditsOverview
-- WHERE company_id = ? AND is_active = true ORDER BY created_at DESC LIMIT 1
CREATE INDEX IF NOT EXISTS company_plans_company_active_created_idx
ON company_plans (company_id, created_at DESC)
WHERE is_active = true;
-- Admin queue: status filter + activity sort (COALESCE last_message / updated)
CREATE INDEX IF NOT EXISTS support_tickets_status_activity_idx
ON support_tickets (status, (COALESCE(last_message_at, updated_at)) DESC);
-- Staff inbox: assigned tickets by activity
CREATE INDEX IF NOT EXISTS support_tickets_assignee_activity_idx
ON support_tickets (assignee_admin_user_id, status, (COALESCE(last_message_at, updated_at)) DESC)
WHERE assignee_admin_user_id IS NOT NULL;
-- User ticket list: company + creator + activity
CREATE INDEX IF NOT EXISTS support_tickets_user_activity_idx
ON support_tickets (company_id, created_by_user_id, (COALESCE(last_message_at, updated_at)) DESC);
-- +goose Down
DROP INDEX IF EXISTS support_tickets_user_activity_idx;
DROP INDEX IF EXISTS support_tickets_assignee_activity_idx;
DROP INDEX IF EXISTS support_tickets_status_activity_idx;
DROP INDEX IF EXISTS company_plans_company_active_created_idx;
@@ -0,0 +1,26 @@
-- +goose Up
-- Explicit legacy packaging flag (A1 / migrated limited-nav).
-- Name-pattern detection still applies when this is false.
ALTER TABLE plans
ADD COLUMN IF NOT EXISTS is_legacy BOOLEAN NOT NULL DEFAULT false;
UPDATE plans SET is_legacy = true
WHERE is_legacy = false
AND (
lower(name) = 'legacy'
OR lower(name) LIKE '%legacy%'
OR lower(name) = 'a1'
OR lower(name) LIKE 'a1 %'
OR lower(name) LIKE 'a1-%'
OR lower(name) LIKE 'a1_%'
OR lower(name) LIKE '%a1 slovenija%'
);
CREATE INDEX IF NOT EXISTS plans_is_legacy_idx
ON plans (is_legacy)
WHERE is_legacy = true;
-- +goose Down
DROP INDEX IF EXISTS plans_is_legacy_idx;
ALTER TABLE plans DROP COLUMN IF EXISTS is_legacy;
+23
View File
@@ -0,0 +1,23 @@
-- +goose Up
-- Platform staff roles for least-privilege admin/support desk access.
-- NULL staff_role + is_platform_admin=true keeps legacy full-admin behavior.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS staff_role TEXT
CHECK (staff_role IS NULL OR staff_role IN ('admin', 'developer', 'support_staff'));
CREATE INDEX IF NOT EXISTS users_staff_role_idx
ON users (staff_role)
WHERE staff_role IS NOT NULL;
COMMENT ON COLUMN users.staff_role IS
'Platform staff role: admin|developer|support_staff. NULL with is_platform_admin=true = legacy full admin.';
-- Backfill existing platform admins to explicit admin role (idempotent).
UPDATE users
SET staff_role = 'admin', updated_at = now()
WHERE is_platform_admin = true AND staff_role IS NULL;
-- +goose Down
DROP INDEX IF EXISTS users_staff_role_idx;
ALTER TABLE users DROP COLUMN IF EXISTS staff_role;
+60
View File
@@ -0,0 +1,60 @@
-- +goose Up
-- Support desk: resolver attribution, CSAT storage (ratings API by sibling agent),
-- unassigned queue index, expanded notification kinds.
-- Staff capability uses users.staff_role=support_staff (029_staff_roles).
ALTER TABLE support_tickets
ADD COLUMN IF NOT EXISTS resolved_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS csat_token_hash BYTEA,
ADD COLUMN IF NOT EXISTS csat_invite_sent_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS support_tickets_unassigned_queue_idx
ON support_tickets (status, last_message_at DESC NULLS LAST)
WHERE assignee_admin_user_id IS NULL;
CREATE INDEX IF NOT EXISTS support_tickets_resolved_by_idx
ON support_tickets (resolved_by_user_id)
WHERE resolved_by_user_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS support_csat_ratings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL UNIQUE REFERENCES support_tickets(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
score SMALLINT NOT NULL CHECK (score BETWEEN 1 AND 5),
comment TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS support_csat_ratings_created_idx
ON support_csat_ratings (created_at DESC);
ALTER TABLE support_notifications
DROP CONSTRAINT IF EXISTS support_notifications_kind_check;
ALTER TABLE support_notifications
ADD CONSTRAINT support_notifications_kind_check
CHECK (kind IN (
'ticket_created',
'agent_reply',
'status_changed',
'user_reply',
'ticket_claimed',
'csat_requested'
));
-- +goose Down
ALTER TABLE support_notifications DROP CONSTRAINT IF EXISTS support_notifications_kind_check;
ALTER TABLE support_notifications
ADD CONSTRAINT support_notifications_kind_check
CHECK (kind IN ('ticket_created', 'agent_reply', 'status_changed', 'user_reply'));
DROP INDEX IF EXISTS support_csat_ratings_created_idx;
DROP TABLE IF EXISTS support_csat_ratings;
DROP INDEX IF EXISTS support_tickets_resolved_by_idx;
DROP INDEX IF EXISTS support_tickets_unassigned_queue_idx;
ALTER TABLE support_tickets
DROP COLUMN IF EXISTS csat_invite_sent_at,
DROP COLUMN IF EXISTS csat_token_hash,
DROP COLUMN IF EXISTS resolved_by_user_id;
@@ -0,0 +1,187 @@
-- +goose Up
-- Support auto series (agent 5/10): richer ticket detail, message auto metadata,
-- category taxonomy, activity timeline. Agent 3: add KB/templates in 032_* (do not
-- re-ALTER these ticket/message columns). Agent 4: reuse auto_reply_* + message
-- auto_* fields for AI outcomes — do not duplicate.
ALTER TABLE support_tickets DROP CONSTRAINT IF EXISTS support_tickets_category_check;
CREATE TABLE IF NOT EXISTS support_categories (
slug TEXT PRIMARY KEY,
label TEXT NOT NULL,
parent_slug TEXT REFERENCES support_categories(slug) ON DELETE SET NULL,
sort_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
match_intents TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO support_categories (slug, label, sort_order, match_intents) VALUES
('billing', 'Billing', 10, ARRAY['billing','invoice','stripe']),
('billing_credits', 'Billing / credits', 11, ARRAY['credits','quota']),
('bug', 'Bug / error', 20, ARRAY['bug','error','crash']),
('account', 'Account / access', 30, ARRAY['login','password','access']),
('integrations', 'Integrations', 40, ARRAY['woocommerce','shopify','api']),
('processing', 'Processing / AI', 50, ARRAY['processing','ai','gpt']),
('export', 'Export / channels', 60, ARRAY['export','feed','channel']),
('other', 'Other', 100, ARRAY[]::TEXT[])
ON CONFLICT (slug) DO NOTHING;
ALTER TABLE support_tickets
ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS related_product_id UUID REFERENCES processed_products(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS related_sku TEXT,
ADD COLUMN IF NOT EXISTS customer_context JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS auto_reply_disabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS auto_reply_status TEXT NOT NULL DEFAULT 'none',
ADD COLUMN IF NOT EXISTS auto_reply_attempted_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS auto_reply_message_id UUID,
ADD COLUMN IF NOT EXISTS auto_reply_meta JSONB NOT NULL DEFAULT '{}'::jsonb;
-- +goose StatementBegin
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'support_tickets_auto_reply_message_id_fkey'
) THEN
ALTER TABLE support_tickets
ADD CONSTRAINT support_tickets_auto_reply_message_id_fkey
FOREIGN KEY (auto_reply_message_id) REFERENCES support_messages(id) ON DELETE SET NULL;
END IF;
END $$;
-- +goose StatementEnd
ALTER TABLE support_tickets DROP CONSTRAINT IF EXISTS support_tickets_auto_reply_status_check;
ALTER TABLE support_tickets
ADD CONSTRAINT support_tickets_auto_reply_status_check
CHECK (auto_reply_status IN (
'none', 'matched', 'ai_draft', 'ai_sent', 'skipped', 'failed', 'handed_off'
));
ALTER TABLE support_tickets DROP CONSTRAINT IF EXISTS support_tickets_related_sku_len_check;
ALTER TABLE support_tickets
ADD CONSTRAINT support_tickets_related_sku_len_check
CHECK (related_sku IS NULL OR char_length(related_sku) <= 128);
CREATE INDEX IF NOT EXISTS support_tickets_tags_gin_idx
ON support_tickets USING GIN (tags);
CREATE INDEX IF NOT EXISTS support_tickets_auto_reply_status_idx
ON support_tickets (auto_reply_status)
WHERE auto_reply_status <> 'none';
CREATE INDEX IF NOT EXISTS support_tickets_related_product_idx
ON support_tickets (related_product_id)
WHERE related_product_id IS NOT NULL;
ALTER TABLE support_messages
ADD COLUMN IF NOT EXISTS auto_source TEXT,
ADD COLUMN IF NOT EXISTS auto_confidence REAL,
ADD COLUMN IF NOT EXISTS auto_ref_type TEXT,
ADD COLUMN IF NOT EXISTS auto_ref_id UUID,
ADD COLUMN IF NOT EXISTS is_auto_reply BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE support_messages DROP CONSTRAINT IF EXISTS support_messages_auto_source_check;
ALTER TABLE support_messages
ADD CONSTRAINT support_messages_auto_source_check
CHECK (auto_source IS NULL OR auto_source IN ('kb', 'template', 'ai'));
CREATE TABLE IF NOT EXISTS support_ticket_activity (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
actor_role TEXT NOT NULL DEFAULT 'system'
CHECK (actor_role IN ('user', 'agent', 'system', 'ai')),
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
message_id UUID REFERENCES support_messages(id) ON DELETE SET NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT support_ticket_activity_kind_check CHECK (kind IN (
'created',
'customer_message',
'agent_message',
'system_message',
'auto_reply',
'ai_draft',
'ai_sent',
'ai_failed',
'handed_off',
'claimed',
'released',
'status_changed',
'auto_disabled',
'auto_enabled',
'note'
))
);
CREATE INDEX IF NOT EXISTS support_ticket_activity_ticket_idx
ON support_ticket_activity (ticket_id, created_at ASC);
CREATE INDEX IF NOT EXISTS support_ticket_activity_company_idx
ON support_ticket_activity (company_id, created_at DESC);
ALTER TABLE support_notifications DROP CONSTRAINT IF EXISTS support_notifications_kind_check;
ALTER TABLE support_notifications
ADD CONSTRAINT support_notifications_kind_check
CHECK (kind IN (
'ticket_created',
'agent_reply',
'status_changed',
'user_reply',
'ticket_claimed',
'csat_requested',
'auto_reply'
));
-- +goose Down
ALTER TABLE support_notifications DROP CONSTRAINT IF EXISTS support_notifications_kind_check;
ALTER TABLE support_notifications
ADD CONSTRAINT support_notifications_kind_check
CHECK (kind IN (
'ticket_created',
'agent_reply',
'status_changed',
'user_reply',
'ticket_claimed',
'csat_requested'
));
DROP INDEX IF EXISTS support_ticket_activity_company_idx;
DROP INDEX IF EXISTS support_ticket_activity_ticket_idx;
DROP TABLE IF EXISTS support_ticket_activity;
ALTER TABLE support_messages DROP CONSTRAINT IF EXISTS support_messages_auto_source_check;
ALTER TABLE support_messages
DROP COLUMN IF EXISTS is_auto_reply,
DROP COLUMN IF EXISTS auto_ref_id,
DROP COLUMN IF EXISTS auto_ref_type,
DROP COLUMN IF EXISTS auto_confidence,
DROP COLUMN IF EXISTS auto_source;
DROP INDEX IF EXISTS support_tickets_related_product_idx;
DROP INDEX IF EXISTS support_tickets_auto_reply_status_idx;
DROP INDEX IF EXISTS support_tickets_tags_gin_idx;
ALTER TABLE support_tickets DROP CONSTRAINT IF EXISTS support_tickets_auto_reply_message_id_fkey;
ALTER TABLE support_tickets DROP CONSTRAINT IF EXISTS support_tickets_related_sku_len_check;
ALTER TABLE support_tickets DROP CONSTRAINT IF EXISTS support_tickets_auto_reply_status_check;
ALTER TABLE support_tickets
DROP COLUMN IF EXISTS auto_reply_meta,
DROP COLUMN IF EXISTS auto_reply_message_id,
DROP COLUMN IF EXISTS auto_reply_attempted_at,
DROP COLUMN IF EXISTS auto_reply_status,
DROP COLUMN IF EXISTS auto_reply_disabled,
DROP COLUMN IF EXISTS customer_context,
DROP COLUMN IF EXISTS related_sku,
DROP COLUMN IF EXISTS related_product_id,
DROP COLUMN IF EXISTS tags;
DROP TABLE IF EXISTS support_categories;
ALTER TABLE support_tickets DROP CONSTRAINT IF EXISTS support_tickets_category_check;
ALTER TABLE support_tickets
ADD CONSTRAINT support_tickets_category_check
CHECK (category IN ('billing', 'bug', 'account', 'other'));
@@ -0,0 +1,76 @@
-- +goose Up
-- Agent 3/10: knowledge base + reply templates + FAQ match config.
-- Ticket/message auto_* columns live in 031_support_ticket_detail.sql (agent 5).
CREATE TABLE IF NOT EXISTS support_kb_articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL,
title TEXT NOT NULL,
body_md TEXT NOT NULL,
category_slugs TEXT[] NOT NULL DEFAULT '{}',
keywords TEXT[] NOT NULL DEFAULT '{}',
intent_keys TEXT[] NOT NULL DEFAULT '{}',
is_published BOOLEAN NOT NULL DEFAULT false,
priority_weight INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT support_kb_articles_slug_chk CHECK (char_length(slug) BETWEEN 1 AND 120),
CONSTRAINT support_kb_articles_title_chk CHECK (char_length(title) BETWEEN 1 AND 200),
CONSTRAINT support_kb_articles_body_chk CHECK (char_length(body_md) BETWEEN 1 AND 20000)
);
CREATE UNIQUE INDEX IF NOT EXISTS support_kb_articles_slug_uidx
ON support_kb_articles (slug);
CREATE INDEX IF NOT EXISTS support_kb_articles_published_idx
ON support_kb_articles (is_published, priority_weight DESC)
WHERE is_published = true;
CREATE INDEX IF NOT EXISTS support_kb_articles_keywords_gin
ON support_kb_articles USING GIN (keywords);
CREATE TABLE IF NOT EXISTS support_reply_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
body TEXT NOT NULL,
category_slugs TEXT[] NOT NULL DEFAULT '{}',
keywords TEXT[] NOT NULL DEFAULT '{}',
intent_keys TEXT[] NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT true,
priority_weight INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT support_reply_templates_name_chk CHECK (char_length(name) BETWEEN 1 AND 120),
CONSTRAINT support_reply_templates_body_chk CHECK (char_length(body) BETWEEN 1 AND 10000)
);
CREATE INDEX IF NOT EXISTS support_reply_templates_active_idx
ON support_reply_templates (is_active, priority_weight DESC)
WHERE is_active = true;
CREATE INDEX IF NOT EXISTS support_reply_templates_keywords_gin
ON support_reply_templates USING GIN (keywords);
CREATE TABLE IF NOT EXISTS support_auto_config (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT false,
faq_enabled BOOLEAN NOT NULL DEFAULT true,
match_confidence_threshold DOUBLE PRECISION NOT NULL DEFAULT 0.78
CHECK (match_confidence_threshold >= 0.50 AND match_confidence_threshold <= 0.95),
retry_on_first_customer_reply BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO support_auto_config (id)
VALUES (1)
ON CONFLICT (id) DO NOTHING;
-- +goose Down
DROP TABLE IF EXISTS support_auto_config;
DROP INDEX IF EXISTS support_reply_templates_keywords_gin;
DROP INDEX IF EXISTS support_reply_templates_active_idx;
DROP TABLE IF EXISTS support_reply_templates;
DROP INDEX IF EXISTS support_kb_articles_keywords_gin;
DROP INDEX IF EXISTS support_kb_articles_published_idx;
DROP INDEX IF EXISTS support_kb_articles_slug_uidx;
DROP TABLE IF EXISTS support_kb_articles;
@@ -0,0 +1,44 @@
-- +goose Up
-- AI fallback columns for support_auto_config (agent 6 admin UI + agent 4 worker).
ALTER TABLE support_auto_config
ADD COLUMN IF NOT EXISTS ai_enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS ai_confidence_threshold DOUBLE PRECISION NOT NULL DEFAULT 0.65,
ADD COLUMN IF NOT EXISTS ai_delivery TEXT NOT NULL DEFAULT 'draft',
ADD COLUMN IF NOT EXISTS ai_use_global_support_role BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS ai_provider_override TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS ai_model_override TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS ai_base_url_override TEXT NOT NULL DEFAULT '';
-- +goose StatementBegin
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'support_auto_config_ai_confidence_chk'
) THEN
ALTER TABLE support_auto_config
ADD CONSTRAINT support_auto_config_ai_confidence_chk
CHECK (ai_confidence_threshold >= 0.50 AND ai_confidence_threshold <= 0.95);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'support_auto_config_ai_delivery_chk'
) THEN
ALTER TABLE support_auto_config
ADD CONSTRAINT support_auto_config_ai_delivery_chk
CHECK (ai_delivery IN ('draft', 'auto_send'));
END IF;
END $$;
-- +goose StatementEnd
-- +goose Down
ALTER TABLE support_auto_config DROP CONSTRAINT IF EXISTS support_auto_config_ai_delivery_chk;
ALTER TABLE support_auto_config DROP CONSTRAINT IF EXISTS support_auto_config_ai_confidence_chk;
ALTER TABLE support_auto_config
DROP COLUMN IF EXISTS ai_base_url_override,
DROP COLUMN IF EXISTS ai_model_override,
DROP COLUMN IF EXISTS ai_provider_override,
DROP COLUMN IF EXISTS ai_use_global_support_role,
DROP COLUMN IF EXISTS ai_delivery,
DROP COLUMN IF EXISTS ai_confidence_threshold,
DROP COLUMN IF EXISTS ai_enabled;
@@ -0,0 +1,31 @@
-- +goose Up
-- Agent 4/10: async AI fallback jobs after FAQ match miss.
CREATE TABLE IF NOT EXISTS support_auto_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'running', 'done', 'failed')),
attempt INT NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS support_auto_jobs_ticket_active_uidx
ON support_auto_jobs (ticket_id)
WHERE status IN ('pending', 'running');
CREATE INDEX IF NOT EXISTS support_auto_jobs_status_created_idx
ON support_auto_jobs (status, created_at ASC)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS support_auto_jobs_company_idx
ON support_auto_jobs (company_id, created_at DESC);
-- +goose Down
DROP INDEX IF EXISTS support_auto_jobs_company_idx;
DROP INDEX IF EXISTS support_auto_jobs_status_created_idx;
DROP INDEX IF EXISTS support_auto_jobs_ticket_active_uidx;
DROP TABLE IF EXISTS support_auto_jobs;
@@ -0,0 +1,16 @@
-- +goose Up
-- Agent 9/10: idempotent public auto-reply + disabled-ticket index (security/perf).
-- Jobs table + inflight unique index: 034_support_auto_jobs.sql (agent 4).
CREATE UNIQUE INDEX IF NOT EXISTS support_messages_one_public_auto_per_ticket_uidx
ON support_messages (ticket_id)
WHERE COALESCE(is_auto_reply, false) = true
AND COALESCE(is_internal_note, false) = false;
CREATE INDEX IF NOT EXISTS support_tickets_auto_reply_disabled_idx
ON support_tickets (company_id, updated_at DESC)
WHERE COALESCE(auto_reply_disabled, false) = true;
-- +goose Down
DROP INDEX IF EXISTS support_tickets_auto_reply_disabled_idx;
DROP INDEX IF EXISTS support_messages_one_public_auto_per_ticket_uidx;
@@ -0,0 +1,22 @@
-- +goose Up
-- Agent 5/10: richer KB bodies + category filter index (images on disk under UPLOAD_DIR/support-kb).
ALTER TABLE support_kb_articles
DROP CONSTRAINT IF EXISTS support_kb_articles_body_chk;
ALTER TABLE support_kb_articles
ADD CONSTRAINT support_kb_articles_body_chk
CHECK (char_length(body_md) BETWEEN 1 AND 100000);
CREATE INDEX IF NOT EXISTS support_kb_articles_category_slugs_gin
ON support_kb_articles USING GIN (category_slugs);
-- +goose Down
DROP INDEX IF EXISTS support_kb_articles_category_slugs_gin;
ALTER TABLE support_kb_articles
DROP CONSTRAINT IF EXISTS support_kb_articles_body_chk;
ALTER TABLE support_kb_articles
ADD CONSTRAINT support_kb_articles_body_chk
CHECK (char_length(body_md) BETWEEN 1 AND 20000);
+75
View File
@@ -0,0 +1,75 @@
-- +goose Up
-- Sales contact leads + admin-prepared payment quotes (custom/Enterprise deals).
CREATE TABLE sales_leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
email TEXT NOT NULL,
company_name TEXT,
phone TEXT,
message TEXT NOT NULL,
estimated_skus INT,
source TEXT NOT NULL DEFAULT 'pricing'
CHECK (char_length(source) BETWEEN 1 AND 64),
status TEXT NOT NULL DEFAULT 'new'
CHECK (status IN ('new', 'contacted', 'quoted', 'won', 'closed')),
company_id UUID REFERENCES companies(id) ON DELETE SET NULL,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
admin_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT sales_leads_name_len CHECK (char_length(btrim(name)) BETWEEN 1 AND 200),
CONSTRAINT sales_leads_email_len CHECK (char_length(btrim(email)) BETWEEN 3 AND 320),
CONSTRAINT sales_leads_message_len CHECK (char_length(btrim(message)) BETWEEN 1 AND 10000),
CONSTRAINT sales_leads_company_name_len CHECK (company_name IS NULL OR char_length(company_name) <= 200),
CONSTRAINT sales_leads_phone_len CHECK (phone IS NULL OR char_length(phone) <= 40),
CONSTRAINT sales_leads_admin_notes_len CHECK (admin_notes IS NULL OR char_length(admin_notes) <= 10000),
CONSTRAINT sales_leads_estimated_skus_chk CHECK (estimated_skus IS NULL OR estimated_skus >= 0)
);
CREATE INDEX sales_leads_status_created_idx ON sales_leads (status, created_at DESC);
CREATE INDEX sales_leads_email_idx ON sales_leads (lower(email), created_at DESC);
CREATE INDEX sales_leads_company_idx ON sales_leads (company_id) WHERE company_id IS NOT NULL;
CREATE TABLE sales_quotes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID NOT NULL REFERENCES sales_leads(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
plan_id BIGINT REFERENCES plans(id) ON DELETE SET NULL,
plan_name TEXT NOT NULL,
monthly_credits INT NOT NULL DEFAULT 0,
max_products INT,
currency TEXT NOT NULL DEFAULT 'usd',
total_amount_cents INT NOT NULL,
installment_count INT NOT NULL DEFAULT 1,
installment_interval TEXT NOT NULL DEFAULT 'month'
CHECK (installment_interval IN ('month', 'quarter', 'year')),
installment_amount_cents INT NOT NULL,
term_months INT,
stripe_product_id TEXT,
stripe_price_id TEXT,
stripe_checkout_session_id TEXT,
checkout_url TEXT,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'ready', 'sent', 'paid', 'canceled')),
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT sales_quotes_plan_name_len CHECK (char_length(btrim(plan_name)) BETWEEN 1 AND 120),
CONSTRAINT sales_quotes_currency_len CHECK (char_length(currency) BETWEEN 3 AND 10),
CONSTRAINT sales_quotes_total_chk CHECK (total_amount_cents > 0),
CONSTRAINT sales_quotes_installment_count_chk CHECK (installment_count BETWEEN 1 AND 60),
CONSTRAINT sales_quotes_installment_amount_chk CHECK (installment_amount_cents > 0),
CONSTRAINT sales_quotes_monthly_credits_chk CHECK (monthly_credits >= 0),
CONSTRAINT sales_quotes_max_products_chk CHECK (max_products IS NULL OR max_products >= 0),
CONSTRAINT sales_quotes_term_months_chk CHECK (term_months IS NULL OR term_months BETWEEN 1 AND 120)
);
CREATE INDEX sales_quotes_lead_idx ON sales_quotes (lead_id, created_at DESC);
CREATE INDEX sales_quotes_company_status_idx ON sales_quotes (company_id, status, created_at DESC);
CREATE INDEX sales_quotes_status_idx ON sales_quotes (status, created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS sales_quotes;
DROP TABLE IF EXISTS sales_leads;
@@ -0,0 +1,106 @@
-- +goose Up
-- Per-language AI prompts + multi-language product content.
-- ASSUMPTION (backfill): existing single prompts/content map to companies.language
-- (fallback "en" when unset/invalid). Primary language columns on processed_products
-- remain the denormalized view of localized_content[primary].
ALTER TABLE companies
ADD COLUMN IF NOT EXISTS content_languages TEXT[] NOT NULL DEFAULT '{}';
UPDATE companies
SET content_languages = ARRAY[COALESCE(NULLIF(lower(trim(language)), ''), 'en')]
WHERE content_languages = '{}' OR content_languages IS NULL;
ALTER TABLE categories ADD COLUMN IF NOT EXISTS prompts_lang JSONB NOT NULL DEFAULT '{}'::jsonb;
UPDATE categories c
SET prompts_lang = CASE
WHEN COALESCE(c.prompt, '') = '' THEN '{}'::jsonb
ELSE jsonb_build_object(
COALESCE(NULLIF(lower(trim(co.language)), ''), 'en'),
c.prompt
)
END
FROM companies co
WHERE co.id = c.company_id
AND c.prompts_lang = '{}'::jsonb
AND COALESCE(c.prompt, '') <> '';
ALTER TABLE categories DROP COLUMN IF EXISTS prompt;
ALTER TABLE categories RENAME COLUMN prompts_lang TO prompt;
ALTER TABLE categories DROP CONSTRAINT IF EXISTS categories_prompt_is_object;
ALTER TABLE categories
ADD CONSTRAINT categories_prompt_is_object CHECK (jsonb_typeof(prompt) = 'object');
ALTER TABLE ai_prompt_templates
ADD COLUMN IF NOT EXISTS language TEXT NOT NULL DEFAULT '';
UPDATE ai_prompt_templates t
SET language = COALESCE(NULLIF(lower(trim(c.language)), ''), 'en')
FROM companies c
WHERE c.id = t.company_id
AND (t.language IS NULL OR t.language = '');
ALTER TABLE ai_prompt_templates DROP CONSTRAINT IF EXISTS ai_prompt_templates_company_id_prompt_key_key;
ALTER TABLE ai_prompt_templates DROP CONSTRAINT IF EXISTS ai_prompt_templates_company_key_lang_uidx;
ALTER TABLE ai_prompt_templates
ADD CONSTRAINT ai_prompt_templates_company_key_lang_uidx
UNIQUE (company_id, prompt_key, language);
ALTER TABLE ai_prompt_templates DROP CONSTRAINT IF EXISTS ai_prompt_templates_language_fmt;
ALTER TABLE ai_prompt_templates
ADD CONSTRAINT ai_prompt_templates_language_fmt
CHECK (language ~ '^[a-z]{2}$');
ALTER TABLE processed_products
ADD COLUMN IF NOT EXISTS localized_content JSONB NOT NULL DEFAULT '{}'::jsonb;
UPDATE processed_products pp
SET localized_content = jsonb_build_object(
COALESCE(NULLIF(lower(trim(co.language)), ''), 'en'),
jsonb_strip_nulls(jsonb_build_object(
'processed_name', NULLIF(pp.processed_name, ''),
'processed_description', NULLIF(pp.processed_description, ''),
'meta_title', NULLIF(pp.meta_title, ''),
'meta_description', NULLIF(pp.meta_description, '')
))
)
FROM companies co
WHERE co.id = pp.company_id
AND pp.localized_content = '{}'::jsonb
AND (
COALESCE(pp.processed_name, '') <> ''
OR COALESCE(pp.processed_description, '') <> ''
OR COALESCE(pp.meta_title, '') <> ''
OR COALESCE(pp.meta_description, '') <> ''
);
ALTER TABLE processed_products DROP CONSTRAINT IF EXISTS processed_products_localized_content_is_object;
ALTER TABLE processed_products
ADD CONSTRAINT processed_products_localized_content_is_object
CHECK (jsonb_typeof(localized_content) = 'object');
-- +goose Down
ALTER TABLE processed_products DROP CONSTRAINT IF EXISTS processed_products_localized_content_is_object;
ALTER TABLE processed_products DROP COLUMN IF EXISTS localized_content;
ALTER TABLE ai_prompt_templates DROP CONSTRAINT IF EXISTS ai_prompt_templates_language_fmt;
ALTER TABLE ai_prompt_templates DROP CONSTRAINT IF EXISTS ai_prompt_templates_company_key_lang_uidx;
ALTER TABLE ai_prompt_templates DROP COLUMN IF EXISTS language;
ALTER TABLE ai_prompt_templates
ADD CONSTRAINT ai_prompt_templates_company_id_prompt_key_key UNIQUE (company_id, prompt_key);
ALTER TABLE categories DROP CONSTRAINT IF EXISTS categories_prompt_is_object;
ALTER TABLE categories ADD COLUMN IF NOT EXISTS prompt_text TEXT;
UPDATE categories SET prompt_text = (
SELECT trim(value)
FROM jsonb_each_text(COALESCE(prompt, '{}'::jsonb))
WHERE trim(value) <> ''
LIMIT 1
);
ALTER TABLE categories DROP COLUMN IF EXISTS prompt;
ALTER TABLE categories RENAME COLUMN prompt_text TO prompt;
ALTER TABLE companies DROP COLUMN IF EXISTS content_languages;
@@ -0,0 +1,11 @@
-- +goose Up
-- Worker liveness signal for /readyz (API probes fail when the poller is dead).
CREATE TABLE IF NOT EXISTS worker_heartbeats (
worker_id TEXT PRIMARY KEY,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS worker_heartbeats;
@@ -0,0 +1,43 @@
-- +goose Up
-- Hot-path indexes for feed sync / processing jobs at large-tenant scale.
-- Product list composites already covered in 018-023; these tables still had
-- only single-column company_id / feed_id / job_id / status indexes.
-- Pattern mirrors 034_support_auto_jobs (list composite + pending claim partial).
-- ListSyncJobs: WHERE company_id=? AND feed_id=? ORDER BY created_at DESC
-- Also covers enqueue dedupe filter on (company_id, feed_id) + status.
CREATE INDEX IF NOT EXISTS feed_sync_jobs_company_feed_created_idx
ON feed_sync_jobs (company_id, feed_id, created_at DESC);
-- ClaimNext sync worker: WHERE status='pending' ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED
CREATE INDEX IF NOT EXISTS feed_sync_jobs_pending_claim_idx
ON feed_sync_jobs (created_at ASC)
WHERE status = 'pending';
-- Last completed content_hash per feed (skip unchanged sync):
-- WHERE feed_id=? AND status='completed' AND content_hash IS NOT NULL ORDER BY completed_at DESC
CREATE INDEX IF NOT EXISTS feed_sync_jobs_feed_completed_hash_idx
ON feed_sync_jobs (feed_id, completed_at DESC)
WHERE status = 'completed' AND content_hash IS NOT NULL AND content_hash <> '';
-- ListJobs / ListProcessingJobs: WHERE company_id=? ORDER BY created_at DESC
CREATE INDEX IF NOT EXISTS processing_jobs_company_created_idx
ON processing_jobs (company_id, created_at DESC);
-- ClaimNextPendingJob: WHERE status='pending' ORDER BY priority DESC, created_at LIMIT 1 FOR UPDATE SKIP LOCKED
CREATE INDEX IF NOT EXISTS processing_jobs_pending_claim_idx
ON processing_jobs (priority DESC, created_at ASC)
WHERE status = 'pending';
-- ListPendingJobProducts / claim batch (million-item jobs):
-- WHERE job_id=? AND status='pending' [OR stuck processing] ORDER BY created_at
CREATE INDEX IF NOT EXISTS processing_job_products_job_status_created_idx
ON processing_job_products (job_id, status, created_at ASC);
-- +goose Down
DROP INDEX IF EXISTS processing_job_products_job_status_created_idx;
DROP INDEX IF EXISTS processing_jobs_pending_claim_idx;
DROP INDEX IF EXISTS processing_jobs_company_created_idx;
DROP INDEX IF EXISTS feed_sync_jobs_feed_completed_hash_idx;
DROP INDEX IF EXISTS feed_sync_jobs_pending_claim_idx;
DROP INDEX IF EXISTS feed_sync_jobs_company_feed_created_idx;
@@ -0,0 +1,20 @@
-- +goose Up
-- Self-serve forgot-password: durable hashed one-time reset tokens (not must_set_password / invites).
CREATE TABLE password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT password_reset_tokens_token_hash_len CHECK (char_length(token_hash) = 64)
);
CREATE UNIQUE INDEX password_reset_tokens_token_hash_uidx ON password_reset_tokens (token_hash);
CREATE INDEX password_reset_tokens_user_pending_idx
ON password_reset_tokens (user_id, expires_at DESC)
WHERE consumed_at IS NULL;
-- +goose Down
DROP TABLE IF EXISTS password_reset_tokens;
@@ -0,0 +1,9 @@
-- +goose Up
-- Cookie sessions (scs) store opaque token/blob rows with no user_id index.
-- Bump session_version on password reset so RequireSession rejects stale cookies.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS session_version INT NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE users DROP COLUMN IF EXISTS session_version;