diff --git a/demo/demo.sql b/demo/demo.sql index 4e0b066..5296d7e 100644 --- a/demo/demo.sql +++ b/demo/demo.sql @@ -51,13 +51,13 @@ select add_contact (1, 'Bou', '41414142C', '', '333 333 333', 'bou@pesebre.cat', select add_contact (1, 'Rabadà', '41414143K', '', '444 444 444', 'rabada@pesebre.cat', '', 'C/ De les Ovelles, 6', 'Fornells de la Selva', 'Girona', '17458', 'ES', array['pesebre', 'persona']); alter sequence product_product_id_seq restart; -select add_product(1, 'Or', 'Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a l’aigua règia.', '55.92', array[2]); -select add_product(1, 'Encens', 'Goma resina fragrant que desprèn una olor característica quan es crema.', '2.15', array[2]); -select add_product(1, 'Mirra', 'Goma resinosa aromàtica de color gris groguenc i gust amargant.', '6.90', array[2]); -select add_product(1, 'Paper higiènic (pack de 32 U)', 'Paper que s’usa per mantenir la higiene personal després de defecar o orinar.', '7.99', array[4]); -select add_product(1, 'Cavall Fort', 'Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.', '3.64', array[2]); -select add_product(1, 'Palla', 'Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.', '25.00', array[3]); -select add_product(1, 'Teia', 'Fusta resinosa de pi i d’altres arbres, provinent sobretot del cor de l’arbre, que crema amb molta facilitat.', '7.00', array[2]); +select add_product(1, 'Or', 'Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a l’aigua règia.', '55.92', array[2], array['metall']); +select add_product(1, 'Encens', 'Goma resina fragrant que desprèn una olor característica quan es crema.', '2.15', array[2], array['resina']); +select add_product(1, 'Mirra', 'Goma resinosa aromàtica de color gris groguenc i gust amargant.', '6.90', array[2], array['resina']); +select add_product(1, 'Paper higiènic (pack de 32 U)', 'Paper que s’usa per mantenir la higiene personal després de defecar o orinar.', '7.99', array[4], array['necessitat']); +select add_product(1, 'Cavall Fort', 'Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.', '3.64', array[2], array['revista']); +select add_product(1, 'Palla', 'Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.', '25.00', array[3], array['necessitat']); +select add_product(1, 'Teia', 'Fusta resinosa de pi i d’altres arbres, provinent sobretot del cor de l’arbre, que crema amb molta facilitat.', '7.00', array[2], array['obsolet']); alter sequence invoice_invoice_id_seq restart; alter sequence invoice_product_invoice_product_id_seq restart; diff --git a/deploy/add_product.sql b/deploy/add_product.sql index c3910af..fdb205b 100644 --- a/deploy/add_product.sql +++ b/deploy/add_product.sql @@ -5,12 +5,14 @@ -- requires: parse_price -- requires: company -- requires: currency +-- requires: tag_name +-- requires: tag_product begin; set search_path to numerus, public; -create or replace function add_product(company_id integer, name text, description text, price text, taxes integer[]) returns uuid +create or replace function add_product(company_id integer, name text, description text, price text, taxes integer[], tags tag_name[]) returns uuid as $$ declare pid integer; @@ -28,12 +30,14 @@ begin select pid, tax_id from unnest(taxes) as tax(tax_id); + perform tag_product(company_id, pid, tags); + return pslug; end; $$ language plpgsql; -revoke execute on function add_product(integer, text, text, text, integer[]) from public; -grant execute on function add_product(integer, text, text, text, integer[]) to invoicer; -grant execute on function add_product(integer, text, text, text, integer[]) to admin; +revoke execute on function add_product(integer, text, text, text, integer[], tag_name[]) from public; +grant execute on function add_product(integer, text, text, text, integer[], tag_name[]) to invoicer; +grant execute on function add_product(integer, text, text, text, integer[], tag_name[]) to admin; commit; diff --git a/deploy/edit_product.sql b/deploy/edit_product.sql index e15b4cd..56f298e 100644 --- a/deploy/edit_product.sql +++ b/deploy/edit_product.sql @@ -5,15 +5,18 @@ -- requires: parse_price -- requires: company -- requires: currency +-- requires: tag_name +-- requires: tag_product begin; set search_path to numerus, public; -create or replace function edit_product(slug uuid, name text, description text, price text, taxes integer[]) returns boolean +create or replace function edit_product(slug uuid, name text, description text, price text, taxes integer[], tags tag_name[]) returns boolean as $$ declare pid integer; + company integer; begin update product set name = edit_product.name @@ -23,8 +26,8 @@ begin join currency using (currency_code) where product.company_id = company.company_id and product.slug = edit_product.slug - returning product_id - into pid; + returning product_id, product.company_id + into pid, company; if pid is null then return false; @@ -36,12 +39,14 @@ begin select pid, tax_id from unnest(taxes) as tax(tax_id); + perform tag_product(company, pid, tags); + return true; end; $$ language plpgsql; -revoke execute on function edit_product(uuid, text, text, text, integer[]) from public; -grant execute on function edit_product(uuid, text, text, text, integer[]) to invoicer; -grant execute on function edit_product(uuid, text, text, text, integer[]) to admin; +revoke execute on function edit_product(uuid, text, text, text, integer[], tag_name[]) from public; +grant execute on function edit_product(uuid, text, text, text, integer[], tag_name[]) to invoicer; +grant execute on function edit_product(uuid, text, text, text, integer[], tag_name[]) to admin; commit; diff --git a/deploy/product_tag.sql b/deploy/product_tag.sql new file mode 100644 index 0000000..1a73cea --- /dev/null +++ b/deploy/product_tag.sql @@ -0,0 +1,31 @@ +-- Deploy numerus:product_tag to pg +-- requires: schema_numerus +-- requires: tag +-- requires: product + +begin; + +set search_path to numerus, public; + +create table product_tag ( + product_id integer not null references product, + tag_id integer not null references tag, + primary key (product_id, tag_id) +); + +grant select, insert, update, delete on table product_tag to invoicer; +grant select, insert, update, delete on table product_tag to admin; + +alter table product_tag enable row level security; + +create policy company_policy +on product_tag +using ( + exists( + select 1 + from product + where product.product_id = product_tag.product_id + ) +); + +commit; diff --git a/deploy/tag_product.sql b/deploy/tag_product.sql new file mode 100644 index 0000000..5b1933d --- /dev/null +++ b/deploy/tag_product.sql @@ -0,0 +1,22 @@ +-- Deploy numerus:tag_product to pg +-- requires: schema_numerus +-- requires: tag_name +-- requires: tag_relation +-- requires: product_tag + +begin; + +set search_path to numerus, public; + +create or replace function tag_product (company_id integer, product_id integer, tags tag_name[]) returns void as +$$ + select tag_relation('product_tag', 'product_id', company_id, product_id, tags); +$$ + language sql +; + +revoke execute on function tag_product(integer, integer, tag_name[]) from public; +grant execute on function tag_product(integer, integer, tag_name[]) to invoicer; +grant execute on function tag_product(integer, integer, tag_name[]) to admin; + +commit; diff --git a/pkg/products.go b/pkg/products.go index b49ba21..baed0e4 100644 --- a/pkg/products.go +++ b/pkg/products.go @@ -14,6 +14,7 @@ type ProductEntry struct { Slug string Name string Price string + Tags []string } type productsIndexPage struct { @@ -23,8 +24,9 @@ type productsIndexPage struct { func IndexProducts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { conn := getConn(r) company := mustGetCompany(r) + tag := r.URL.Query().Get("tag") page := &productsIndexPage{ - Products: mustGetProductEntries(r.Context(), conn, company), + Products: mustCollectProductEntries(r.Context(), conn, company, tag), } mustRenderMainTemplate(w, r, "products/index.gohtml", page) } @@ -40,7 +42,7 @@ func GetProductForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa mustRenderNewProductForm(w, r, form) return } - if notFoundErrorOrPanic(conn.QueryRow(r.Context(), "select product.name, product.description, to_price(price, decimal_digits), array_agg(tax_id) from product left join product_tax using (product_id) join company using (company_id) join currency using (currency_code) where product.slug = $1 group by product_id, product.name, product.description, price, decimal_digits", slug).Scan(form.Name, form.Description, form.Price, form.Tax)) { + if !form.MustFillFromDatabase(r.Context(), conn, slug) { http.NotFound(w, r) return } @@ -74,12 +76,12 @@ func HandleAddProduct(w http.ResponseWriter, r *http.Request, _ httprouter.Param return } taxes := mustSliceAtoi(form.Tax.Selected) - conn.MustExec(r.Context(), "select add_product($1, $2, $3, $4, $5)", company.Id, form.Name, form.Description, form.Price, taxes) + conn.MustExec(r.Context(), "select add_product($1, $2, $3, $4, $5, $6)", company.Id, form.Name, form.Description, form.Price, taxes, form.Tags) http.Redirect(w, r, companyURI(company, "/products"), http.StatusSeeOther) } func sliceAtoi(s []string) ([]int, error) { - i := []int{} + var i []int for _, vs := range s { vi, err := strconv.Atoi(vs) if err != nil { @@ -118,14 +120,29 @@ func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprout } slug := params[0].Value taxes := mustSliceAtoi(form.Tax.Selected) - if ok := conn.MustGetBool(r.Context(), "select edit_product($1, $2, $3, $4, $5)", slug, form.Name, form.Description, form.Price, taxes); !ok { + if ok := conn.MustGetBool(r.Context(), "select edit_product($1, $2, $3, $4, $5, $6)", slug, form.Name, form.Description, form.Price, taxes, form.Tags); !ok { http.NotFound(w, r) } http.Redirect(w, r, companyURI(company, "/products/"+slug), http.StatusSeeOther) } -func mustGetProductEntries(ctx context.Context, conn *Conn, company *Company) []*ProductEntry { - rows, err := conn.Query(ctx, "select product.slug, product.name, to_price(price, decimal_digits) from product join company using (company_id) join currency using (currency_code) where company_id = $1 order by name", company.Id) +func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company, tag string) []*ProductEntry { + rows, err := conn.Query(ctx, ` + select product.slug + , product.name + , to_price(price, decimal_digits) + , array_agg(coalesce(tag.name::text, '')) + from product + join company using (company_id) + join currency using (currency_code) + left join product_tag using (product_id) + left join tag using(tag_id) + where product.company_id = $1 and (($2 = '') or (tag.name = $2)) + group by product.slug + , product.name + , to_price(price, decimal_digits) + order by name + `, company.Id, tag) if err != nil { panic(err) } @@ -134,7 +151,7 @@ func mustGetProductEntries(ctx context.Context, conn *Conn, company *Company) [] var entries []*ProductEntry for rows.Next() { entry := &ProductEntry{} - err = rows.Scan(&entry.Slug, &entry.Name, &entry.Price) + err = rows.Scan(&entry.Slug, &entry.Name, &entry.Price, &entry.Tags) if err != nil { panic(err) } @@ -154,6 +171,7 @@ type productForm struct { Description *InputField Price *InputField Tax *SelectField + Tags *TagsField } func newProductForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *productForm { @@ -187,6 +205,10 @@ func newProductForm(ctx context.Context, conn *Conn, locale *Locale, company *Co Multiple: true, Options: mustGetTaxOptions(ctx, conn, company), }, + Tags: &TagsField{ + Name: "tags", + Label: pgettext("input", "Tags", locale), + }, } } @@ -198,6 +220,7 @@ func (form *productForm) Parse(r *http.Request) error { form.Description.FillValue(r) form.Price.FillValue(r) form.Tax.FillValue(r) + form.Tags.FillValue(r) return nil } @@ -211,3 +234,30 @@ func (form *productForm) Validate() bool { validator.CheckAtMostOneOfEachGroup(form.Tax, gettext("You can only select a tax of each class.", form.locale)) return validator.AllOK() } + +func (form *productForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool { + return !notFoundErrorOrPanic(conn.QueryRow(ctx, ` + select product.name + , product.description + , to_price(price, decimal_digits) + , array_agg(tax_id) + , string_agg(tag.name, ',') + from product + left join product_tax using (product_id) + left join product_tag using (product_id) + left join tag using(company_id, tag_id) + join company using (company_id) + join currency using (currency_code) + where product.slug = $1 + group by product_id + , product.name + , product.description + , price + , decimal_digits + `, slug).Scan( + form.Name, + form.Description, + form.Price, + form.Tax, + form.Tags)) +} diff --git a/revert/add_product.sql b/revert/add_product.sql index 447637a..36af919 100644 --- a/revert/add_product.sql +++ b/revert/add_product.sql @@ -2,6 +2,6 @@ begin; -drop function if exists numerus.add_product(integer, text, text, text, integer[]); +drop function if exists numerus.add_product(integer, text, text, text, integer[], numerus.tag_name[]); commit; diff --git a/revert/edit_product.sql b/revert/edit_product.sql index 9a7e38c..5b84886 100644 --- a/revert/edit_product.sql +++ b/revert/edit_product.sql @@ -2,6 +2,6 @@ begin; -drop function if exists numerus.edit_product(uuid, text, text, text, integer[]); +drop function if exists numerus.edit_product(uuid, text, text, text, integer[], numerus.tag_name[]); commit; diff --git a/revert/product_tag.sql b/revert/product_tag.sql new file mode 100644 index 0000000..b934c7e --- /dev/null +++ b/revert/product_tag.sql @@ -0,0 +1,7 @@ +-- Revert numerus:product_tag from pg + +begin; + +drop table if exists numerus.product_tag; + +commit; diff --git a/revert/tag_product.sql b/revert/tag_product.sql new file mode 100644 index 0000000..1639790 --- /dev/null +++ b/revert/tag_product.sql @@ -0,0 +1,7 @@ +-- Revert numerus:tag_product from pg + +begin; + +drop function if exists numerus.tag_product(integer, integer, numerus.tag_name[]); + +commit; diff --git a/sqitch.plan b/sqitch.plan index aba6c61..5aa922b 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -52,16 +52,18 @@ product_tax [schema_numerus product tax] 2023-02-08T11:36:49Z jordi fita mas # Add relation for invoice discount_rate [schema_numerus] 2023-02-10T17:22:40Z jordi fita mas # Add domain for discount rates invoice_product [schema_numerus invoice discount_rate] 2023-02-10T17:07:08Z jordi fita mas # Add relation for invoice product -add_product [schema_numerus product product_tax parse_price company currency] 2023-02-14T10:32:18Z jordi fita mas # Add function to add new products -edit_product [schema_numerus product product_tax parse_price company currency] 2023-02-14T11:06:03Z jordi fita mas # Add function to edit products +tag_name [schema_numerus] 2023-03-10T11:06:11Z jordi fita mas # Add domain for tag names +tag [schema_numerus tag_name] 2023-03-10T11:04:24Z jordi fita mas # Add relation for tags +tag_relation [schema_numerus tag tag_name] 2023-03-25T17:40:52Z jordi fita mas # Add function to tag “relations” +product_tag [schema_numerus tag product] 2023-03-26T10:54:23Z jordi fita mas # Add relation for product tag +tag_product [schema_numerus tag_name tag_relation product_tag] 2023-03-26T11:01:17Z jordi fita mas # Add function to tag products +add_product [schema_numerus product product_tax parse_price company currency tag_name tag_product] 2023-02-14T10:32:18Z jordi fita mas # Add function to add new products +edit_product [schema_numerus product product_tax parse_price company currency tag_name tag_product] 2023-02-14T11:06:03Z jordi fita mas # Add function to edit products invoice_product_tax [schema_numerus invoice_product tax tax_rate] 2023-02-15T13:20:30Z jordi fita mas # Add relation for taxes in invoice products new_invoice_product [schema_numerus discount_rate] 2023-02-16T21:06:01Z jordi fita mas # Add type for passing products to new invoices invoice_number_counter [schema_numerus company] 2023-02-17T13:04:48Z jordi fita mas # Add relation to count invoice numbers next_invoice_number [schema_numerus invoice_number_counter] 2023-02-17T13:21:48Z jordi fita mas # Add function to retrieve the next invoice number -tag_name [schema_numerus] 2023-03-10T11:06:11Z jordi fita mas # Add domain for tag names -tag [schema_numerus tag_name] 2023-03-10T11:04:24Z jordi fita mas # Add relation for tags invoice_tag [schema_numerus tag invoice] 2023-03-10T11:37:43Z jordi fita mas # Add relation for invoice tag -tag_relation [schema_numerus tag tag_name] 2023-03-25T17:40:52Z jordi fita mas # Add function to tag “relations” tag_invoice [schema_numerus tag_name tag_relation invoice_tag] 2023-03-25T18:04:02Z jordi fita mas # Add function to tag invoices add_invoice [schema_numerus invoice company currency parse_price new_invoice_product tax invoice_product invoice_product_tax next_invoice_number tag_name tag_invoice] 2023-02-16T21:12:46Z jordi fita mas # Add function to create new invoices invoice_tax_amount [schema_numerus invoice_product invoice_product_tax] 2023-02-22T12:08:35Z jordi fita mas # Add view for invoice tax amount diff --git a/test/add_contact.sql b/test/add_contact.sql index b75b6e6..159dc15 100644 --- a/test/add_contact.sql +++ b/test/add_contact.sql @@ -90,6 +90,8 @@ select bag_eq( $$, 'Should have assigned the tags to contacts' ); + + select * from finish(); diff --git a/test/add_product.sql b/test/add_product.sql index a5b1061..c4c0e46 100644 --- a/test/add_product.sql +++ b/test/add_product.sql @@ -5,22 +5,24 @@ reset client_min_messages; begin; -select plan(14); +select plan(16); set search_path to auth, numerus, public; -select has_function('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]']); -select function_lang_is('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'plpgsql'); -select function_returns('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'uuid'); -select isnt_definer('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]']); -select volatility_is('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'volatile'); -select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'guest', array []::text[]); -select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'invoicer', array ['EXECUTE']); -select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'admin', array ['EXECUTE']); -select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'authenticator', array []::text[]); +select has_function('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]']); +select function_lang_is('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'plpgsql'); +select function_returns('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'uuid'); +select isnt_definer('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]']); +select volatility_is('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'volatile'); +select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'guest', array []::text[]); +select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'invoicer', array ['EXECUTE']); +select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'admin', array ['EXECUTE']); +select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'authenticator', array []::text[]); set client_min_messages to warning; +truncate product_tag cascade; +truncate tag cascade; truncate product_tax cascade; truncate product cascade; truncate tax cascade; @@ -57,17 +59,17 @@ values (3, 1, 11, 'IRPF -15 %', -0.15) select lives_ok( - $$ select add_product(1, 'Product 1', 'Description 1', '12.12', array[3, 4]) $$, + $$ select add_product(1, 'Product 1', 'Description 1', '12.12', array[3, 4], array['tag1']) $$, 'Should be able to add product to first company' ); select lives_ok( - $$ select add_product(2, 'Product 2', 'Description 2', '24.24', array[6]) $$, + $$ select add_product(2, 'Product 2', 'Description 2', '24.24', array[6], array['tag1', 'tag2']) $$, 'Should be able to add product to second company' ); select lives_ok( - $$ select add_product(2, 'Product 3', 'Description 3', '36.36', array[]::integer[]) $$, + $$ select add_product(2, 'Product 3', 'Description 3', '36.36', array[]::integer[], array[]::tag_name[]) $$, 'Should be able to add product without taxes' ); @@ -89,6 +91,25 @@ select bag_eq( 'Should have added the taxes for the products we told to' ); +select bag_eq( + $$ select company_id, name from tag $$, + $$ values (1, 'tag1') + , (2, 'tag1') + , (2, 'tag2') + $$, + 'Should have added all new tags once' +); + +select bag_eq( + $$ select product.name as product_name, tag.name as tag_name from product_tag join product using (product_id) join tag using (tag_id) $$, + $$ values ('Product 1', 'tag1') + , ('Product 2', 'tag1') + , ('Product 2', 'tag2') + $$, + 'Should have assigned the tags to products' +); + + select * from finish(); diff --git a/test/edit_product.sql b/test/edit_product.sql index 2b82ac1..b7460f2 100644 --- a/test/edit_product.sql +++ b/test/edit_product.sql @@ -5,22 +5,24 @@ reset client_min_messages; begin; -select plan(15); +select plan(17); set search_path to auth, numerus, public; -select has_function('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]']); -select function_lang_is('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'plpgsql'); -select function_returns('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'boolean'); -select isnt_definer('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]']); -select volatility_is('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'volatile'); -select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'guest', array []::text[]); -select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'invoicer', array ['EXECUTE']); -select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'admin', array ['EXECUTE']); -select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'authenticator', array []::text[]); +select has_function('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]']); +select function_lang_is('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'plpgsql'); +select function_returns('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'boolean'); +select isnt_definer('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]']); +select volatility_is('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'volatile'); +select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'guest', array []::text[]); +select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'invoicer', array ['EXECUTE']); +select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'admin', array ['EXECUTE']); +select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'authenticator', array []::text[]); set client_min_messages to warning; +truncate product_tag cascade; +truncate tag cascade; truncate product_tax cascade; truncate product cascade; truncate tax cascade; @@ -68,26 +70,40 @@ values (7, 3) , (9, 6) ; +insert into tag (tag_id, company_id, name) +values (10, 1, 'tag1') + , (11, 2, 'tag2') +; +-- edit_product uses the sequence and sometimes it would confict +alter sequence tag_tag_id_seq restart with 15; + +insert into product_tag (product_id, tag_id) +values (7, 10) + , (8, 11) + , (9, 11) +; + + select is( - edit_product('d2300404-bd23-48b3-8e2b-2bbf86dc7bd6', 'Product 1', 'Description 1', '12.12', array[3, 4]), + edit_product('d2300404-bd23-48b3-8e2b-2bbf86dc7bd6', 'Product 1', 'Description 1', '12.12', array[3, 4], array['tag1']), true, 'Should be able to edit product from first company' ); select is( - edit_product('2f085b8b-da90-41fe-b8cf-6ba8d94cfa38', 'Product 2', 'Description 2', '24.24', array[6]), + edit_product('2f085b8b-da90-41fe-b8cf-6ba8d94cfa38', 'Product 2', 'Description 2', '24.24', array[6], array['tag1', 'tag3']), true, 'Should be able to edit product from second company' ); select is( - edit_product('84044d0b-af33-442a-95a6-21efc77260d5', 'Product 3', 'Description 3', '36.36', array[]::integer[]), + edit_product('84044d0b-af33-442a-95a6-21efc77260d5', 'Product 3', 'Description 3', '36.36', array[]::integer[], array[]::tag_name[]), true, 'Should be able to edit a product a remove all taxes' ); select is( - edit_product('87e158d1-a0f5-48a7-854b-b86d7b4bb21c', 'Product 4', 'Description 4', '48.48', array[]::integer[]), + edit_product('87e158d1-a0f5-48a7-854b-b86d7b4bb21c', 'Product 4', 'Description 4', '48.48', array[]::integer[], array[]::tag_name[]), false, 'Should return false when the product does not exist' ); @@ -110,6 +126,25 @@ select bag_eq( 'Should have updated the taxes for the products we told to' ); +select bag_eq( + $$ select company_id, name from tag $$, + $$ values (1, 'tag1') + , (2, 'tag1') + , (2, 'tag2') + , (2, 'tag3') + $$, + 'Should have added all new tags' +); + +select bag_eq( + $$ select product.name as product_name, tag.name as tag_name from product_tag join product using (product_id) join tag using (tag_id) $$, + $$ values ('Product 1', 'tag1') + , ('Product 2', 'tag1') + , ('Product 2', 'tag3') + $$, + 'Should have assigned the tags to products' +); + select * from finish(); diff --git a/test/product_tag.sql b/test/product_tag.sql new file mode 100644 index 0000000..aaa7f4e --- /dev/null +++ b/test/product_tag.sql @@ -0,0 +1,129 @@ +-- Test product_tag +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(23); + +set search_path to numerus, auth, public; + +select has_table('product_tag'); +select has_pk('product_tag'); +select col_is_pk('product_tag', array['product_id', 'tag_id']); +select table_privs_are('product_tag', 'guest', array []::text[]); +select table_privs_are('product_tag', 'invoicer', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('product_tag', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('product_tag', 'authenticator', array []::text[]); + +select has_column('product_tag', 'product_id'); +select col_is_fk('product_tag', 'product_id'); +select fk_ok('product_tag', 'product_id', 'product', 'product_id'); +select col_type_is('product_tag', 'product_id', 'integer'); +select col_not_null('product_tag', 'product_id'); +select col_hasnt_default('product_tag', 'product_id'); + +select has_column('product_tag', 'tag_id'); +select col_is_fk('product_tag', 'tag_id'); +select fk_ok('product_tag', 'tag_id', 'tag', 'tag_id'); +select col_type_is('product_tag', 'tag_id', 'integer'); +select col_not_null('product_tag', 'tag_id'); +select col_hasnt_default('product_tag', 'tag_id'); + + +set client_min_messages to warning; +truncate product_tag cascade; +truncate product cascade; +truncate tag cascade; +truncate company_user cascade; +truncate company cascade; +truncate payment_method cascade; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (user_id, email, name, password, role, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +set constraints "company_default_payment_method_id_fkey" deferred; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 222) + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 444) +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (444, 4, 'cash', 'cash') + , (222, 2, 'cash', 'cash') +; + +set constraints "company_default_payment_method_id_fkey" immediate; + +insert into company_user (company_id, user_id) +values (2, 1) + , (4, 5) +; + +insert into product (product_id, company_id, name, description, price) +values (6, 2, 'Product 1', 'Description 1', 1200) + , (8, 4, 'Product 2', 'Description 2', 2400) +; + +insert into tag (tag_id, company_id, name) +values (14, 2, 'web') + , (15, 2, 'design') + , (16, 4, 'product') + , (17, 4, 'development') + , (18, 4, 'something-else') + , (19, 4, 'design') +; + +insert into product_tag (product_id, tag_id) +values (6, 14) + , (6, 15) + , (8, 18) +; + +prepare product_tag_data as +select product_id, tag_id +from product_tag +; + +set role invoicer; +select is_empty('product_tag_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog'); +select bag_eq( + 'product_tag_data', + $$ values ( 6, 14 ) + , ( 6, 15 ) + $$, + 'Should only list product tags of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog'); +select bag_eq( + 'product_tag_data', + $$ values ( 8, 18 ) + $$, + 'Should only list product tags of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie'); +select throws_ok( + 'product_tag_data', + '42501', 'permission denied for table product_tag', + 'Should not allow select to guest users' +); + + +select * +from finish(); + +rollback; + diff --git a/test/tag_product.sql b/test/tag_product.sql new file mode 100644 index 0000000..306c11f --- /dev/null +++ b/test/tag_product.sql @@ -0,0 +1,124 @@ +-- Test tag_product +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(17); + +set search_path to numerus, auth, public; + +select has_function('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]']); +select function_lang_is('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'sql'); +select function_returns('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'void'); +select isnt_definer('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]']); +select volatility_is('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'volatile'); +select function_privs_are('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'guest', array []::text[]); +select function_privs_are('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'invoicer', array ['EXECUTE']); +select function_privs_are('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'admin', array ['EXECUTE']); +select function_privs_are('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'authenticator', array []::text[]); + + +set client_min_messages to warning; +truncate product_tag cascade; +truncate tag cascade; +truncate product cascade; +truncate payment_method cascade; +truncate company cascade; +reset client_min_messages; + +set constraints "company_default_payment_method_id_fkey" deferred; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id) +values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 111) +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (111, 1, 'cash', 'cash') + , (112, 1, 'bank', 'send money to my bank account') +; + +set constraints "company_default_payment_method_id_fkey" immediate; + +insert into tag (tag_id, company_id, name) +values (10, 1, 'tag1') + , (11, 1, 'tag2') +; +-- tag_product uses the sequence and sometimes it would confict +alter sequence tag_tag_id_seq restart with 15; + + +insert into product (product_id, company_id, name, description, price) +values (12, 1, 'Product 1', 'Description 1', 1200) + , (13, 1, 'Product 2', 'Description 2', 2400) +; + +insert into product_tag (product_id, tag_id) +values (12, 10) + , (13, 11) +; + +prepare current_tags as +select product_id, tag.name +from product +join product_tag using (product_id) +join tag using (tag_id); + +select lives_ok( + $$ select tag_product(1, 12, array['tag1']) $$, + 'Should be able to keep the same tags to the product' +); + +select bag_eq( + 'current_tags', + $$ values (12, 'tag1') + , (13, 'tag2') + $$, + 'Should not have changed any product tag' +); + +select lives_ok( + $$ select tag_product(1, 12, array['tag1', 'tag2']) $$, + 'Should be able to add tag2 product' +); + +select bag_eq( + 'current_tags', + $$ values (12, 'tag1') + , (12, 'tag2') + , (13, 'tag2') + $$, + 'Should have added tag2 to product' +); + +select lives_ok( + $$ select tag_product(1, 13, array['tag3']) $$, + 'Should be able to replace all tags of an product with a new one' +); + +select bag_eq( + 'current_tags', + $$ values (12, 'tag1') + , (12, 'tag2') + , (13, 'tag3') + $$, + 'Should have set tag3 to product' +); + +select lives_ok( + $$ select tag_product(1, 12, array[]::tag_name[]) $$, + 'Should be able to remove all tags from an product' +); + +select bag_eq( + 'current_tags', + $$ values (13, 'tag3') + $$, + 'Should have remove all tags from product' +); + +select * +from finish(); + +rollback; diff --git a/verify/add_product.sql b/verify/add_product.sql index 2f13b23..de39ba8 100644 --- a/verify/add_product.sql +++ b/verify/add_product.sql @@ -2,6 +2,6 @@ begin; -select has_function_privilege('numerus.add_product(integer, text, text, text, integer[])', 'execute'); +select has_function_privilege('numerus.add_product(integer, text, text, text, integer[], numerus.tag_name[])', 'execute'); rollback; diff --git a/verify/edit_product.sql b/verify/edit_product.sql index e916615..8db0f2f 100644 --- a/verify/edit_product.sql +++ b/verify/edit_product.sql @@ -2,6 +2,6 @@ begin; -select has_function_privilege('numerus.edit_product(uuid, text, text, text, integer[])', 'execute'); +select has_function_privilege('numerus.edit_product(uuid, text, text, text, integer[], numerus.tag_name[])', 'execute'); rollback; diff --git a/verify/product_tag.sql b/verify/product_tag.sql new file mode 100644 index 0000000..a008d85 --- /dev/null +++ b/verify/product_tag.sql @@ -0,0 +1,13 @@ +-- Verify numerus:product_tag on pg + +begin; + +select product_id + , tag_id +from numerus.product_tag +where false; + +select 1 / count(*) from pg_class where oid = 'numerus.product_tag'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'numerus.product_tag'::regclass; + +rollback; diff --git a/verify/tag_product.sql b/verify/tag_product.sql new file mode 100644 index 0000000..cf3af5e --- /dev/null +++ b/verify/tag_product.sql @@ -0,0 +1,7 @@ +-- Verify numerus:tag_product on pg + +begin; + +select has_function_privilege('numerus.tag_product(integer, integer, numerus.tag_name[])', 'execute'); + +rollback; diff --git a/web/template/products/edit.gohtml b/web/template/products/edit.gohtml index e1808dc..afeb7b4 100644 --- a/web/template/products/edit.gohtml +++ b/web/template/products/edit.gohtml @@ -25,6 +25,7 @@ {{ template "input-field" .Description }} {{ template "input-field" .Price }} {{ template "select-field" .Tax }} + {{ template "tags-field" .Tags }}
diff --git a/web/template/products/index.gohtml b/web/template/products/index.gohtml index 5751a9a..f33448e 100644 --- a/web/template/products/index.gohtml +++ b/web/template/products/index.gohtml @@ -23,6 +23,7 @@ {{( pgettext "All" "product" )}} {{( pgettext "Name" "title" )}} + {{( pgettext "Tags" "title" )}} {{( pgettext "Price" "title" )}} @@ -32,6 +33,12 @@ {{ .Name }} + + {{- range $index, $tag := .Tags }} + {{- if gt $index 0 }}, {{ end -}} + {{ . }} + {{- end }} + {{ .Price | formatPrice }} {{- end }} diff --git a/web/template/products/new.gohtml b/web/template/products/new.gohtml index 0d56d11..ac408c8 100644 --- a/web/template/products/new.gohtml +++ b/web/template/products/new.gohtml @@ -24,6 +24,7 @@ {{ template "input-field" .Description }} {{ template "input-field" .Price }} {{ template "select-field" .Tax }} + {{ template "tags-field" .Tags }}