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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 *;
|
||||
@@ -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 *;
|
||||
@@ -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;
|
||||
@@ -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 *;
|
||||
@@ -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 *;
|
||||
@@ -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 *;
|
||||
@@ -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 *;
|
||||
@@ -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 *;
|
||||
@@ -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 *;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user