-- We try to ensure every table has `created_at` and `updated_at` columns, which can help immensely with debugging
-- and auditing.
--
-- While `created_at` can just be `default now()`, setting `updated_at` on update requires a trigger which
-- is a lot of boilerplate. These two functions save us from writing that every time as instead we can just do
--
-- select create_updated_at_trigger('
');
--
-- after a `CREATE TABLE`.
CREATE OR REPLACE FUNCTION set_updated_at ()
RETURNS trigger
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$
LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION create_updated_at_trigger (tablename regclass)
RETURNS void
AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at
BEFORE UPDATE
ON %s
FOR EACH ROW
WHEN (OLD is distinct from NEW)
EXECUTE FUNCTION set_updated_at();', tablename);
END;
$$
LANGUAGE plpgsql;
-- NOTE This table is only meant to contain one row!
CREATE TABLE "migrations" (
"revision" integer NOT NULL
);
CREATE TABLE "user" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"email" text NOT NULL UNIQUE,
"password_hash" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"profile_image" text
);
SELECT create_updated_at_trigger('"user"');
CREATE TABLE "organization" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"created_by" integer REFERENCES "user" ("id") ON DELETE SET NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"logo_url" text
);
SELECT create_updated_at_trigger('organization');
CREATE TABLE "user_organization" (
"user_id" integer NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
"organization_id" integer NOT NULL REFERENCES "organization" (
"id"
) ON DELETE CASCADE,
CONSTRAINT "user_organization_uq" UNIQUE ("user_id", "organization_id")
);
-- NOTE: Roles are defined in application code
CREATE TABLE "user_organization_role" (
"user_id" integer NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
"organization_id" integer NOT NULL REFERENCES "organization" (
"id"
) ON DELETE CASCADE,
"role_id" integer NOT NULL,
CONSTRAINT "user_organization_role_uq" UNIQUE (
"user_id", "organization_id", "role_id"
)
);
-- NOTE: Roles are defined in application code
CREATE TABLE "global_user_role" (
"user_id" integer NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
"role_id" integer NOT NULL,
CONSTRAINT "global_user_role_uq" UNIQUE ("user_id", "role_id")
);
CREATE TABLE "group" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"external_id" text,
"organization_id" integer NOT NULL REFERENCES "organization" (
"id"
) ON DELETE CASCADE,
"name" text NOT NULL,
"description" text NOT NULL,
"created_by" integer REFERENCES "user" ("id") ON DELETE SET NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "group_external_id_organization_id_uq" UNIQUE (
"external_id", "organization_id"
),
CONSTRAINT "group_slug_organization_id_uq" UNIQUE (
"slug", "organization_id"
)
);
SELECT create_updated_at_trigger('group');
CREATE TABLE "group_user" (
"user_id" integer NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
"group_id" integer NOT NULL REFERENCES "group" ("id") ON DELETE CASCADE,
CONSTRAINT "group_user_uq" UNIQUE ("user_id", "group_id")
);
CREATE TABLE "collection" (
"id" serial PRIMARY KEY,
"organization_id" integer REFERENCES "organization" (
"id"
) ON DELETE CASCADE,
"slug" text NOT NULL,
"external_id" text,
"name" jsonb NOT NULL DEFAULT '{}',
"created_by" integer REFERENCES "user" ("id") ON DELETE SET NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"image_url" text,
"acronym" jsonb NOT NULL DEFAULT '{}',
CONSTRAINT "collection_external_id_organization_id_uq" UNIQUE (
"external_id", "organization_id"
),
CONSTRAINT "collection_slug_organization_id_uq" UNIQUE (
"slug", "organization_id"
)
);
SELECT create_updated_at_trigger('collection');
CREATE TABLE "group_collection" (
"collection_id" integer NOT NULL REFERENCES "collection" ("id") ON DELETE CASCADE,
"group_id" integer NOT NULL REFERENCES "group" ("id") ON DELETE CASCADE,
CONSTRAINT "group_collection_uq" UNIQUE ("collection_id", "group_id")
);
CREATE TABLE "style" (
"id" serial PRIMARY KEY,
"organization_id" integer NOT NULL REFERENCES "organization" (
"id"
) ON DELETE CASCADE,
"slug" text NOT NULL,
"external_id" text,
"number" text NOT NULL,
"name" jsonb NOT NULL DEFAULT '{}',
"created_by" integer REFERENCES "user" ("id") ON DELETE SET NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"description" jsonb NOT NULL DEFAULT '{}',
"core" boolean,
"country_of_origin" text,
"tariff_no" text,
CONSTRAINT "style_external_id_organization_id_uq" UNIQUE (
"external_id", "organization_id"
),
CONSTRAINT "style_slug_organization_id_uq" UNIQUE (
"slug", "organization_id"
)
);
SELECT create_updated_at_trigger('style');
CREATE TABLE "color" (
"id" serial PRIMARY KEY,
"organization_id" integer NOT NULL REFERENCES "organization" (
"id"
) ON DELETE CASCADE,
"slug" text NOT NULL,
"external_id" text,
"style_id" integer NOT NULL REFERENCES "style" ("id") ON DELETE CASCADE,
"number" text NOT NULL,
"name" jsonb NOT NULL DEFAULT '{}',
"created_by" integer REFERENCES "user" ("id") ON DELETE SET NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "color_external_id_organization_id_uq" UNIQUE (
"external_id", "organization_id"
),
CONSTRAINT "color_slug_organization_id_uq" UNIQUE (
"slug", "organization_id"
)
);
SELECT create_updated_at_trigger('color');
CREATE TABLE "size" (
"id" serial PRIMARY KEY,
"organization_id" integer NOT NULL REFERENCES "organization" (
"id"
) ON DELETE CASCADE,
"slug" text NOT NULL,
"external_id" text,
"color_id" integer NOT NULL REFERENCES "color" ("id") ON DELETE CASCADE,
"number" text NOT NULL,
"name" jsonb NOT NULL DEFAULT '{}',
"created_by" integer REFERENCES "user" ("id") ON DELETE SET NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"service_item" boolean,
"delivery_period" date,
CONSTRAINT "size_external_id_organization_id_uq" UNIQUE (
"external_id", "organization_id"
),
CONSTRAINT "size_slug_organization_id_uq" UNIQUE ("slug", "organization_id")
);
SELECT create_updated_at_trigger('size');
CREATE TABLE "size_collection" (
"size_id" integer NOT NULL REFERENCES "size" ("id") ON DELETE CASCADE,
"collection_id" integer NOT NULL REFERENCES "collection" (
"id"
) ON DELETE CASCADE,
"position" integer NOT NULL
);
CREATE TABLE "new_collection_style" (
"collection_id" integer NOT NULL REFERENCES "collection" (
"id"
) ON DELETE CASCADE,
"style_id" integer NOT NULL REFERENCES "style" ("id") ON DELETE CASCADE,
"is_new" boolean NOT NULL,
CONSTRAINT "new_collection_style_collection_id_style_id_uq" UNIQUE (
"collection_id", "style_id"
)
);
CREATE TABLE "new_collection_color" (
"collection_id" integer NOT NULL REFERENCES "collection" (
"id"
) ON DELETE CASCADE,
"color_id" integer NOT NULL REFERENCES "color" ("id") ON DELETE CASCADE,
"is_new" boolean NOT NULL,
CONSTRAINT "new_collection_style_collection_id_color_id_uq" UNIQUE (
"collection_id", "color_id"
)
);
CREATE TABLE "image" (
"id" serial PRIMARY KEY,
"organization_id" integer NOT NULL REFERENCES "organization" (
"id"
) ON DELETE CASCADE,
"url" text NOT NULL,
"external_id" text,
"external_checksum" text,
"position" int NOT NULL,
"color_id" integer NOT NULL REFERENCES "color" ("id") ON DELETE CASCADE,
"uploaded_by" integer REFERENCES "user" ("id") ON DELETE SET NULL,
"uploaded_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "image_external_id_organization_id_uq" UNIQUE (
"external_id", "organization_id"
)
);
SELECT create_updated_at_trigger('image');
CREATE TABLE "attributetype" (
"id" serial PRIMARY KEY,
"organization_id" integer NOT NULL REFERENCES "organization" (
"id"
) ON DELETE CASCADE,
"name" jsonb NOT NULL DEFAULT '{}',
"slug" text NOT NULL,
"external_id" text,
"created_by" integer REFERENCES "user" ("id") ON DELETE SET NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "attributetype_external_id_organization_id_uq" UNIQUE (
"external_id", "organization_id"
),
CONSTRAINT "attributetype_slug_organization_id_uq" UNIQUE (
"slug", "organization_id"
)
);
SELECT create_updated_at_trigger('attributetype');
CREATE TABLE "attribute" (
"id" serial PRIMARY KEY,
"organization_id" integer NOT NULL REFERENCES "organization" (
"id"
) ON DELETE CASCADE,
"type_id" integer NOT NULL REFERENCES "attributetype" (
"id"
) ON DELETE CASCADE,
"title" jsonb NOT NULL DEFAULT '{}',
"description" jsonb NOT NULL DEFAULT '{}',
"slug" text NOT NULL,
"external_id" text,
"created_by" integer REFERENCES "user" ("id") ON DELETE SET NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "attribute_external_id_organization_id_uq" UNIQUE (
"external_id", "organization_id"
),
CONSTRAINT "attribute_slug_organization_id_uq" UNIQUE (
"slug", "organization_id"
)
);
SELECT create_updated_at_trigger('attribute');
CREATE TABLE "style_attribute" (
"style_id" integer NOT NULL REFERENCES "style" ("id") ON DELETE CASCADE,
"attribute_id" integer NOT NULL REFERENCES "attribute" (
"id"
) ON DELETE CASCADE,
"position" integer NOT NULL,
CONSTRAINT "style_id_attribute_id_uq" UNIQUE ("style_id", "attribute_id")
);
CREATE TYPE pricelistcategory AS ENUM (
'PreBook',
'AtOnce',
'Blanket',
'Direct',
'B2C'
);
CREATE TABLE "pricelist" (
"id" serial PRIMARY KEY,
"organization_id" integer NOT NULL REFERENCES "organization" (
"id"
) ON DELETE CASCADE,
"name" text NOT NULL,
"slug" text NOT NULL,
"external_id" text,
"created_by" integer REFERENCES "user" ("id") ON DELETE SET NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"category" pricelistcategory NOT NULL,
CONSTRAINT "pricelist_external_id_organization_id_uq" UNIQUE (
"external_id", "organization_id"
),
CONSTRAINT "pricelist_slug_organization_id_uq" UNIQUE (
"slug", "organization_id"
)
);
SELECT create_updated_at_trigger('pricelist');
CREATE TABLE "group_pricelist" (
"pricelist_id" integer NOT NULL REFERENCES "pricelist" ("id") ON DELETE CASCADE,
"group_id" integer NOT NULL REFERENCES "group" ("id") ON DELETE CASCADE,
CONSTRAINT "group_pricelist_uq" UNIQUE ("pricelist_id", "group_id")
);
CREATE TYPE pricetype AS ENUM (
'Unit',
'Retail'
);
CREATE TYPE dateprice AS (
"amount" numeric (10, 2),
"start" date,
"end" date
);
CREATE TABLE "priceset" (
"id" serial PRIMARY KEY,
"organization_id" integer NOT NULL REFERENCES "organization" (
"id"
) ON DELETE CASCADE,
"type" pricetype NOT NULL,
"currency" text NOT NULL,
"uom" text,
"list_id" integer NOT NULL REFERENCES "pricelist" ("id") ON DELETE CASCADE,
"external_id" text,
"style_id" integer NOT NULL REFERENCES "style" ("id") ON DELETE CASCADE,
"created_by" integer REFERENCES "user" ("id") ON DELETE SET NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "price_external_id_organization_id_uq" UNIQUE (
"external_id", "organization_id"
)
);
SELECT create_updated_at_trigger('priceset');
CREATE TABLE "price" (
"priceset_id" integer NOT NULL REFERENCES "priceset" (
"id"
) ON DELETE CASCADE,
"amount" numeric(10, 2) NOT NULL,
"start" date NOT NULL,
"end" date NOT NULL
);
CREATE TABLE "collectionpricing" (
"id" serial PRIMARY KEY,
"collection_id" integer NOT NULL REFERENCES "collection" ("id") ON DELETE CASCADE,
"pricelist_category" pricelistcategory NOT NULL,
"price_date" date NOT NULL,
"created_by" integer REFERENCES "user" ("id") ON DELETE SET NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "collection_pricelist_category_uq" UNIQUE (
"collection_id", "pricelist_category"
)
);
CREATE TYPE collectionpricingvalue AS (
"pricelist_category" pricelistcategory,
"price_date" date
);
CREATE TABLE "category" (
"id" serial PRIMARY KEY,
"organization_id" integer NOT NULL REFERENCES "organization" (
"id"
) ON DELETE CASCADE,
"slug" text NOT NULL,
"external_id" text,
"name" jsonb NOT NULL DEFAULT '{}',
"created_by" integer REFERENCES "user" ("id") ON DELETE SET NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "category_external_id_organization_id_uq" UNIQUE (
"external_id", "organization_id"
),
CONSTRAINT "category_slug_organization_id_uq" UNIQUE (
"slug", "organization_id"
)
);
SELECT create_updated_at_trigger('category');
CREATE TABLE "style_category" (
"style_id" integer NOT NULL REFERENCES "style" ("id") ON DELETE CASCADE,
"category_id" integer NOT NULL REFERENCES "category" (
"id"
) ON DELETE CASCADE,
"position" integer NOT NULL,
CONSTRAINT "style_id_category_id_uq" UNIQUE ("style_id", "category_id")
);
-- Set migrate revision
CREATE OR REPLACE FUNCTION set_migrate_revision (int)
RETURNS int
AS $$
DELETE FROM migrations;
INSERT INTO migrations VALUES ($1) RETURNING $1;
$$
LANGUAGE sql;
-- Takes a style ID and an array of category IDs and associates them in the style_category table. Existing associations for the given style ID are deleted.
CREATE OR REPLACE FUNCTION associate_style_categories (int, int[])
RETURNS TABLE (
category_id int
)
AS $$
DELETE FROM style_category WHERE style_id = $1;
INSERT INTO style_category (
style_id,
category_id,
position
)
SELECT
$1,
t.category_id,
t.position
FROM
unnest($2) WITH ORDINALITY AS t (category_id, position)
RETURNING
category_id;
$$
LANGUAGE sql;
-- Takes a style ID and an array of attribute IDs and associates them in the style_attribute table. Existing associations for the given style ID are deleted.
CREATE OR REPLACE FUNCTION associate_style_attributes (int, int[])
RETURNS TABLE (
attribute_id int
)
AS $$
DELETE FROM style_attribute WHERE style_id = $1;
INSERT INTO style_attribute (
style_id,
attribute_id,
position
)
SELECT
$1,
t.attribute_id,
t.position
FROM
unnest($2) WITH ORDINALITY AS t (attribute_id, position)
RETURNING
attribute_id;
$$
LANGUAGE sql;
-- Takes a collection ID and an array of size IDs and associates them in the size_collection table. Existing associations for the given collection ID are deleted.
CREATE OR REPLACE FUNCTION associate_collection_sizes (int, int[])
RETURNS int
AS $$
DELETE FROM size_collection WHERE collection_id = $1;
INSERT INTO size_collection (
collection_id,
size_id,
position
)
SELECT
$1,
t.size_id,
t.position
FROM
unnest($2) WITH ORDINALITY AS t (size_id, position);
SELECT coalesce(array_length($2, 1), 0);
$$
LANGUAGE sql;
-- Takes a collection ID and an array of style IDs and associates them in the new_collection_style table. Existing associations for the given collection ID are deleted.
CREATE OR REPLACE FUNCTION set_new_collection_styles (int, int[])
RETURNS int
AS $$
DELETE FROM new_collection_style WHERE collection_id = $1;
INSERT INTO new_collection_style (
collection_id,
style_id,
is_new
)
SELECT
$1,
t.style_id,
true
FROM
unnest($2) AS t (style_id);
SELECT coalesce(array_length($2, 1), 0);
$$
LANGUAGE sql;
-- Takes a collection ID and an array of color IDs and associates them in the new_collection_color table. Existing associations for the given collection ID are deleted.
CREATE OR REPLACE FUNCTION set_new_collection_colors (int, int[])
RETURNS int
AS $$
DELETE FROM new_collection_color WHERE collection_id = $1;
INSERT INTO new_collection_color (
collection_id,
color_id,
is_new
)
SELECT
$1,
t.color_id,
true
FROM
unnest($2) AS t (color_id);
SELECT coalesce(array_length($2, 1), 0);
$$
LANGUAGE sql;
-- Takes a group ID and an array of user IDs and associates them in the group_user table. Existing associations for the given group ID are deleted.
CREATE OR REPLACE FUNCTION replace_group_users (int, int[])
RETURNS int
AS $$
DELETE FROM group_user WHERE group_id = $1;
INSERT INTO group_user (
group_id,
user_id
)
SELECT
$1,
t.user_id
FROM
unnest($2) AS t (user_id);
SELECT coalesce(array_length($2, 1), 0);
$$
LANGUAGE sql;
-- Takes a group ID and an array of collection IDs and associates them in the group_collection table. Existing associations for the given group ID are deleted.
CREATE OR REPLACE FUNCTION replace_group_collections (int, int[])
RETURNS int
AS $$
DELETE FROM group_collection WHERE group_id = $1;
INSERT INTO group_collection (
group_id,
collection_id
)
SELECT
$1,
t.collection_id
FROM
unnest($2) AS t (collection_id);
SELECT coalesce(array_length($2, 1), 0);
$$
LANGUAGE sql;
-- Takes a group ID and an array of pricelist IDs and associates them in the group_pricelist table. Existing associations for the given group ID are deleted.
CREATE OR REPLACE FUNCTION replace_group_pricelists (int, int[])
RETURNS int
AS $$
DELETE FROM group_pricelist WHERE group_id = $1;
INSERT INTO group_pricelist (
group_id,
pricelist_id
)
SELECT
$1,
t.pricelist_id
FROM
unnest($2) AS t (pricelist_id);
SELECT coalesce(array_length($2, 1), 0);
$$
LANGUAGE sql;
-- Takes a priceset ID and an array of dateprice composite type values, and creates them. Existing prices for the given priceset ID are deleted.
CREATE OR REPLACE FUNCTION replace_prices (int, dateprice[])
RETURNS int
AS $$
DELETE FROM price WHERE priceset_id = $1;
INSERT INTO price
SELECT
$1 AS priceset_id,
p."amount",
p."start",
p."end"
FROM
unnest($2::dateprice[]) p;
SELECT coalesce(array_length($2, 1), 0);
$$
LANGUAGE sql;
-- Takes a collection ID and an array of collectionpricing composite type values, and creates them. Existing collectionpricing entries for the given collection ID are deleted.
CREATE OR REPLACE FUNCTION replace_collection_pricing (int, collectionpricingvalue[])
RETURNS int
AS $$
DELETE FROM collectionpricing WHERE collection_id = $1;
INSERT INTO collectionpricing (collection_id, pricelist_category, price_date)
SELECT
$1 AS collection_id,
cpv.pricelist_category,
cpv.price_date
FROM
unnest($2::collectionpricingvalue[]) cpv;
SELECT coalesce(array_length($2, 1), 0);
$$
LANGUAGE sql;
-- Ensure that superuser group exists and has access to all collections and pricelists
CREATE OR REPLACE FUNCTION ensure_superuser_access (org_id int)
RETURNS int
AS $$
DECLARE superuser_group_id int;
BEGIN
INSERT INTO "group" (
organization_id,
name,
description,
slug,
external_id
) VALUES (org_id, 'Superusers', 'Has full access to pricelists and collections within this organization.', 'superusers', 'superusers')
-- TODO: Is it possible to also check for organization_id+slug conflict?
ON CONFLICT ON CONSTRAINT group_external_id_organization_id_uq DO UPDATE SET
name = EXCLUDED.name,
slug = EXCLUDED.slug,
external_id = EXCLUDED.external_id
RETURNING
id INTO superuser_group_id;
INSERT INTO group_collection (group_id, collection_id)
SELECT superuser_group_id AS group_id, id AS collection_id FROM collection ON CONFLICT DO NOTHING;
INSERT INTO group_pricelist (group_id, pricelist_id)
SELECT superuser_group_id AS group_id, id AS pricelist_id FROM pricelist ON CONFLICT DO NOTHING;
RETURN superuser_group_id;
END
$$
LANGUAGE plpgsql;
-- Used to speed up joins between collection, pricelist and extracting the corresponding price_date
CREATE MATERIALIZED VIEW collection_pricelist_date AS
SELECT DISTINCT
collectionpricing.collection_id,
priceset.list_id,
collectionpricing.price_date
FROM collectionpricing
INNER JOIN
size_collection ON size_collection.collection_id = collectionpricing.collection_id
INNER JOIN size ON size.id = size_collection.size_id
INNER JOIN color ON color.id = size.color_id
INNER JOIN priceset ON priceset.style_id = color.style_id
INNER JOIN pricelist ON pricelist.id = priceset.list_id
WHERE pricelist.category = collectionpricing.pricelist_category
ORDER BY
collectionpricing.collection_id, priceset.list_id, collectionpricing.price_date;