Compare commits

..

3 Commits

Author SHA1 Message Date
jordi fita mas 4131602fa3 Add tags for contacts too
With Oriol we agreed that contacts should have tags, too, and that the
“tag pool”, as it were, should be shared with the one for invoices (and
all future tags we might add).

I added the contact_tag relation and tag_contact function, just like
with invoices, and then realized that the SQL queries that Go had to
execute were becoming “complex” enough: i had to get not only the slug,
but the contact id to call tag_contact, and all inside a transaction.

Therefore, i opted to create the add_contact and edit_contact functions,
that mirror those for invoice and products, so now each “major” section
has these functions.  They also simplified a bit the handling of the
VATIN and phone numbers, because it is now encapsuled inside the
PL/pgSQL function and Go does not know how to assemble the parts.
2023-03-26 01:32:53 +01:00
jordi fita mas 6b73acafe6 Add SQL and helper PL/pgSQL functions to tag invoices
We plan to tag also contacts and products using the same tag relation,
but different invoice_tag, contact_tag, and product_tag relations for
each one.  However, the logic is the same for all three, hence it makes
more sense to put it into a PL/pgSQL with dynamic SQL.  Moreover, the
SQL for tagging in add_invoice and edit_invoice where almost exactly
the same, the only difference was deleting the existing tags when
editing.

I do not execute the tag_relation function in its test suite because
by itself it does nothing without supporting invoice_tag, contact_tag,
or any such relation, so it is being tested in the suite for
tag_invoice.
2023-03-26 00:18:29 +01:00
jordi fita mas 7e8ec539ff Add a SnackBar to show HTMx errors
We do not have any design yet for errors and other notifications, so i
followed material design, for now, since we already kind of use their
input fields design.

This time i decided to use AlpineJS because there is not that much HTML
code, and the transitioning is way easier to do in AlpineJS than it
would be with plain JavaScript—not to mention the bugs i would
introduce.
2023-03-25 01:56:26 +01:00
39 changed files with 1163 additions and 70 deletions

View File

@ -41,15 +41,14 @@ values (1, 1, 'Retenció 15 %', -0.15)
, (1, 2, 'IVA 4 %', 0.04)
;
alter sequence tag_tag_id_seq restart;
alter sequence contact_contact_id_seq restart;
insert into contact (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code)
values (1, 'Melcior', 'IR1', 'Rei Blanc', parse_packed_phone_number('0732621', 'IR'), 'melcio@reismags.cat', '', 'C/ Principal, 1', 'Shiraz', 'Fars', '1', 'IR')
, (1, 'Gaspar', 'IN2', 'Rei Ros', parse_packed_phone_number('111', 'IN'), 'gaspar@reismags.cat', '', 'C/ Principal, 2', 'Nova Delhi', 'Delhi', '2', 'IN')
, (1, 'Baltasar', 'YE3', 'Rei Negre', parse_packed_phone_number('1-111-111', 'YE'), 'baltasar@reismags.cat', '', 'C/ Principal, 3', 'Sanaa', 'Sanaa', '3', 'YE')
, (1, 'Caganera', 'ES41414141L', '', parse_packed_phone_number('222 222 222', 'ES'), 'caganera@pesebre.cat', '', 'C/ De lHort, 4', 'Olot', 'Girona', '17800', 'ES')
, (1, 'Bou', 'ES41414142C', '', parse_packed_phone_number('333 333 333', 'ES'), 'bou@pesebre.cat', '', 'C/ De la Palla, 5', 'Sant Climent Sescebes', 'Girona', '17751', 'ES')
, (1, 'Rabadà', 'ES41414143K', '', parse_packed_phone_number('444 444 444', 'ES'), 'rabada@pesebre.cat', '', 'C/ De les Ovelles, 6', 'Fornells de la Selva', 'Girona', '17458', 'ES')
;
select add_contact (1, 'Melcior', '1', 'Rei Blanc', '0732621', 'melcio@reismags.cat', '', 'C/ Principal, 1', 'Shiraz', 'Fars', '1', 'IR', array['pesebre', 'mag']);
select add_contact (1, 'Gaspar', '2', 'Rei Ros', '111', 'gaspar@reismags.cat', '', 'C/ Principal, 2', 'Nova Delhi', 'Delhi', '2', 'IN', array['pesebre', 'mag']);
select add_contact (1, 'Baltasar', '3', 'Rei Negre', '1-111-111', 'baltasar@reismags.cat', '', 'C/ Principal, 3', 'Sanaa', 'Sanaa', '3', 'YE', array['pesebre', 'mag']);
select add_contact (1, 'Caganera', '41414141L', '', '222 222 222', 'caganera@pesebre.cat', '', 'C/ De lHort, 4', 'Olot', 'Girona', '17800', 'ES', array['pesebre', 'persona']);
select add_contact (1, 'Bou', '41414142C', '', '333 333 333', 'bou@pesebre.cat', '', 'C/ De la Palla, 5', 'Sant Climent Sescebes', 'Girona', '17751', 'ES', array['pesebre', 'bestia']);
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 laigua règia.', '55.92', array[2]);
@ -60,7 +59,6 @@ select add_product(1, 'Cavall Fort', 'Revista quinzenal en llengua catalana i de
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 daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.', '7.00', array[2]);
alter sequence tag_tag_id_seq restart;
alter sequence invoice_invoice_id_seq restart;
alter sequence invoice_product_invoice_product_id_seq restart;
select add_invoice(1, '', (current_date - '28 days'::interval)::date, 6, 'Vol esmorzar!', 1, '{producte}','{"(1,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{2})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{2})"}');

38
deploy/add_contact.sql Normal file
View File

@ -0,0 +1,38 @@
-- Deploy numerus:add_contact to pg
-- requires: schema_numerus
-- requires: extension_vat
-- requires: email
-- requires: extension_pg_libphonenumber
-- requires: extension_uri
-- requires: country_code
-- requires: contact
-- requires: tag_name
begin;
set search_path to numerus, public;
create or replace function add_contact(company_id integer, business_name text, vatin text, trade_name text, phone text, email email, web uri, address text, city text, province text, postal_code text, country_code country_code, tags tag_name[]) returns uuid as
$$
declare
cid integer;
cslug uuid;
begin
insert into contact (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code)
values (add_contact.company_id, add_contact.business_name, (add_contact.country_code || add_contact.vatin)::vatin, add_contact.trade_name, parse_packed_phone_number(add_contact.phone, add_contact.country_code), add_contact.email, add_contact.web, add_contact.address, add_contact.city, add_contact.province, add_contact.postal_code, add_contact.country_code)
returning contact_id, slug
into cid, cslug;
perform tag_contact(company_id, cid, tags);
return cslug;
end
$$
language plpgsql
;
revoke execute on function add_contact(integer, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) from public;
grant execute on function add_contact(integer, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) to invoicer;
grant execute on function add_contact(integer, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) to admin;
commit;

View File

@ -10,8 +10,7 @@
-- requires: invoice_product_tax
-- requires: next_invoice_number
-- requires: tag_name
-- requires: tag
-- requires: invoice_tag
-- requires: tag_invoice
begin;
@ -64,19 +63,7 @@ begin
join unnest(product.tax) as ptax(tax_id) using (tax_id);
end loop;
if array_length(tags, 1) > 0 then
insert into tag (company_id, name)
select add_invoice.company, new_tag.name
from unnest (tags) as new_tag(name)
on conflict (company_id, name) do nothing
;
insert into invoice_tag (invoice_id, tag_id)
select iid, tag_id
from tag
join unnest (tags) as new_tag(name) on company_id = add_invoice.company and tag.name = new_tag.name
;
end if;
perform tag_invoice(company, iid, tags);
return pslug;
end;

View File

@ -5,8 +5,6 @@
-- requires: email
-- requires: extension_pg_libphonenumber
-- requires: extension_uri
-- requires: currency_code
-- requires: currency
-- requires: country_code
-- requires: country

31
deploy/contact_tag.sql Normal file
View File

@ -0,0 +1,31 @@
-- Deploy numerus:contact_tag to pg
-- requires: schema_numerus
-- requires: tag
-- requires: contact
begin;
set search_path to numerus, public;
create table contact_tag (
contact_id integer not null references contact,
tag_id integer not null references tag,
primary key (contact_id, tag_id)
);
grant select, insert, update, delete on table contact_tag to invoicer;
grant select, insert, update, delete on table contact_tag to admin;
alter table contact_tag enable row level security;
create policy company_policy
on contact_tag
using (
exists(
select 1
from contact
where contact.contact_id = contact_tag.contact_id
)
);
commit;

55
deploy/edit_contact.sql Normal file
View File

@ -0,0 +1,55 @@
-- Deploy numerus:edit_contact to pg
-- requires: schema_numerus
-- requires: email
-- requires: extension_uri
-- requires: country_code
-- requires: tag_name
-- requires: contact
-- requires: tag_contact
-- requires: extension_vat
-- requires: extension_pg_libphonenumber
begin;
set search_path to numerus, public;
create or replace function edit_contact(contact_slug uuid, business_name text, vatin text, trade_name text, phone text, email email, web uri, address text, city text, province text, postal_code text, country_code country_code, tags tag_name[]) returns uuid as
$$
declare
cid integer;
company integer;
begin
update contact
set business_name = edit_contact.business_name
, vatin = (edit_contact.country_code || edit_contact.vatin)::vatin
, trade_name = edit_contact.trade_name
, phone = parse_packed_phone_number( edit_contact.phone, edit_contact.country_code)
, email = edit_contact.email
, web = edit_contact.web
, address = edit_contact.address
, city = edit_contact.city
, province = edit_contact.province
, postal_code = edit_contact.postal_code
, country_code = edit_contact.country_code
where slug = contact_slug
returning contact_id, company_id
into cid, company
;
if cid is null then
return null;
end if;
perform tag_contact(company, cid, tags);
return contact_slug;
end
$$
language plpgsql
;
revoke execute on function edit_contact(uuid, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) from public;
grant execute on function edit_contact(uuid, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) to invoicer;
grant execute on function edit_contact(uuid, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) to admin;
commit;

View File

@ -8,8 +8,7 @@
-- requires: invoice_product
-- requires: invoice_product_tax
-- requires: tag_name
-- requires: tag
-- requires: invoice_tag
-- requires: tag_invoice
begin;
@ -90,21 +89,7 @@ begin
delete from invoice_product where invoice_product_id = any(products_to_delete);
end if;
delete from invoice_tag where invoice_id = iid;
if array_length(tags, 1) > 0 then
insert into tag (company_id, name)
select company, new_tag.name
from unnest (tags) as new_tag(name)
on conflict (company_id, name) do nothing
;
insert into invoice_tag (invoice_id, tag_id)
select iid, tag_id
from tag
join unnest (tags) as new_tag(name) on company_id = company and tag.name = new_tag.name
;
end if;
perform tag_invoice(company, iid, tags);
return invoice_slug;
end;

22
deploy/tag_contact.sql Normal file
View File

@ -0,0 +1,22 @@
-- Deploy numerus:tag_contact to pg
-- requires: schema_numerus
-- requires: tag_name
-- requires: tag_relation
-- requires: contact_tag
begin;
set search_path to numerus, public;
create or replace function tag_contact (company_id integer, contact_id integer, tags tag_name[]) returns void as
$$
select tag_relation('contact_tag', 'contact_id', company_id, contact_id, tags);
$$
language sql
;
revoke execute on function tag_contact(integer, integer, tag_name[]) from public;
grant execute on function tag_contact(integer, integer, tag_name[]) to invoicer;
grant execute on function tag_contact(integer, integer, tag_name[]) to admin;
commit;

21
deploy/tag_invoice.sql Normal file
View File

@ -0,0 +1,21 @@
-- Deploy numerus:tag_invoice to pg
-- requires: schema_numerus
-- requires: tag_name
-- requires: tag_invoice
-- requires: invoice_tag
begin;
set search_path to numerus, public;
create or replace function tag_invoice(company_id integer, invoice_id integer, tags tag_name[]) returns void as
$$
select tag_relation('invoice_tag', 'invoice_id', company_id, invoice_id, tags);
$$
language sql;
revoke execute on function tag_invoice(integer, integer, tag_name[]) from public;
grant execute on function tag_invoice(integer, integer, tag_name[]) to invoicer;
grant execute on function tag_invoice(integer, integer, tag_name[]) to admin;
commit;

32
deploy/tag_relation.sql Normal file
View File

@ -0,0 +1,32 @@
-- Deploy numerus:tag_relation to pg
-- requires: schema_numerus
-- requires: tag
-- requires: tag_name
begin;
set search_path to numerus, public;
create or replace function tag_relation(relname regclass, attname name, company integer, rowid integer, tags tag_name[]) returns void as
$$
begin
execute format('delete from %I where %I = $1', relname, attname) USING rowid;
if array_length(tags, 1) > 0 then
insert into tag (company_id, name)
select company, new_tag.name
from unnest (tags) as new_tag(name)
on conflict (company_id, name) do nothing
;
execute format('insert into %I (%I, tag_id) select $1, tag_id from tag join unnest ($2) as new_tag(name) on company_id = $3 and tag.name = new_tag.name', relname, attname) USING rowid, tags, company;
end if;
end
$$
language plpgsql;
revoke execute on function tag_relation(regclass, name, integer, integer, tag_name[]) from public;
grant execute on function tag_relation(regclass, name, integer, integer, tag_name[]) to invoicer;
grant execute on function tag_relation(regclass, name, integer, integer, tag_name[]) to admin;
commit;

View File

@ -12,6 +12,7 @@ type ContactEntry struct {
Name string
Email string
Phone string
Tags []string
}
type ContactsIndexPage struct {
@ -21,8 +22,9 @@ type ContactsIndexPage struct {
func IndexContacts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
company := mustGetCompany(r)
tag := r.URL.Query().Get("tag")
page := &ContactsIndexPage{
Contacts: mustGetContactEntries(r.Context(), conn, company),
Contacts: mustCollectContactEntries(r.Context(), conn, company, tag),
}
mustRenderMainTemplate(w, r, "contacts/index.gohtml", page)
}
@ -37,7 +39,7 @@ func GetContactForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa
mustRenderNewContactForm(w, r, form)
return
}
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), "select business_name, substr(vatin::text, 3), trade_name, phone, email, web, address, city, province, postal_code, country_code from contact where slug = $1", slug).Scan(form.BusinessName, form.VATIN, form.TradeName, form.Phone, form.Email, form.Web, form.Address, form.City, form.Province, form.PostalCode, form.Country)) {
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r)
return
}
@ -84,7 +86,7 @@ func HandleAddContact(w http.ResponseWriter, r *http.Request, _ httprouter.Param
return
}
company := mustGetCompany(r)
conn.MustExec(r.Context(), "insert into contact (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code) values ($1, $2, ($12 || $3)::vatin, $4, parse_packed_phone_number($5, $12), $6, $7, $8, $9, $10, $11, $12)", company.Id, form.BusinessName, form.VATIN, form.TradeName, form.Phone, form.Email, form.Web, form.Address, form.City, form.Province, form.PostalCode, form.Country)
conn.MustExec(r.Context(), "select add_contact($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", company.Id, form.BusinessName, form.VATIN, form.TradeName, form.Phone, form.Email, form.Web, form.Address, form.City, form.Province, form.PostalCode, form.Country, form.Tags)
if IsHTMxRequest(r) {
w.Header().Set("HX-Trigger", "closeModal")
w.Header().Set("HX-Refresh", "true")
@ -110,7 +112,7 @@ func HandleUpdateContact(w http.ResponseWriter, r *http.Request, params httprout
mustRenderEditContactForm(w, r, params[0].Value, form)
return
}
slug := conn.MustGetText(r.Context(), "", "update contact set business_name = $1, vatin = ($11 || $2)::vatin, trade_name = $3, phone = parse_packed_phone_number($4, $11), email = $5, web = $6, address = $7, city = $8, province = $9, postal_code = $10, country_code = $11 where slug = $12 returning slug", form.BusinessName, form.VATIN, form.TradeName, form.Phone, form.Email, form.Web, form.Address, form.City, form.Province, form.PostalCode, form.Country, params[0].Value)
slug := conn.MustGetText(r.Context(), "", "select edit_contact($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", params[0].Value, form.BusinessName, form.VATIN, form.TradeName, form.Phone, form.Email, form.Web, form.Address, form.City, form.Province, form.PostalCode, form.Country, form.Tags)
if slug == "" {
http.NotFound(w, r)
}
@ -123,8 +125,23 @@ func HandleUpdateContact(w http.ResponseWriter, r *http.Request, params httprout
}
}
func mustGetContactEntries(ctx context.Context, conn *Conn, company *Company) []*ContactEntry {
rows, err := conn.Query(ctx, "select slug, business_name, email, phone from contact where company_id = $1 order by business_name", company.Id)
func mustCollectContactEntries(ctx context.Context, conn *Conn, company *Company, tag string) []*ContactEntry {
rows, err := conn.Query(ctx, `
select slug
, business_name
, email
, phone
, array_agg(coalesce(tag.name::text, ''))
from contact
left join contact_tag using (contact_id)
left join tag using(tag_id)
where contact.company_id = $1 and (($2 = '') or (tag.name = $2))
group by slug
, business_name
, email
, phone
order by business_name
`, company.Id, tag)
if err != nil {
panic(err)
}
@ -133,7 +150,7 @@ func mustGetContactEntries(ctx context.Context, conn *Conn, company *Company) []
var entries []*ContactEntry
for rows.Next() {
entry := &ContactEntry{}
err = rows.Scan(&entry.Slug, &entry.Name, &entry.Email, &entry.Phone)
err = rows.Scan(&entry.Slug, &entry.Name, &entry.Email, &entry.Phone, &entry.Tags)
if err != nil {
panic(err)
}
@ -159,6 +176,7 @@ type contactForm struct {
Province *InputField
PostalCode *InputField
Country *SelectField
Tags *TagsField
}
func newContactForm(ctx context.Context, conn *Conn, locale *Locale) *contactForm {
@ -250,6 +268,10 @@ func newContactForm(ctx context.Context, conn *Conn, locale *Locale) *contactFor
`autocomplete="country"`,
},
},
Tags: &TagsField{
Name: "tags",
Label: pgettext("input", "Tags", locale),
},
}
}
@ -268,6 +290,7 @@ func (form *contactForm) Parse(r *http.Request) error {
form.Province.FillValue(r)
form.PostalCode.FillValue(r)
form.Country.FillValue(r)
form.Tags.FillValue(r)
return nil
}
@ -300,3 +323,47 @@ func (form *contactForm) Validate(ctx context.Context, conn *Conn) bool {
}
return validator.AllOK()
}
func (form *contactForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
return !notFoundErrorOrPanic(conn.QueryRow(ctx, `
select business_name
, substr(vatin::text, 3)
, trade_name
, phone
, email
, web
, address
, city
, province
, postal_code
, country_code
, string_agg(tag.name, ',')
from contact
left join contact_tag using (contact_id)
left join tag using(tag_id)
where slug = $1
group by business_name
, substr(vatin::text, 3)
, trade_name
, phone
, email
, web
, address
, city
, province
, postal_code
, country_code
`, slug).Scan(
form.BusinessName,
form.VATIN,
form.TradeName,
form.Phone,
form.Email,
form.Web,
form.Address,
form.City,
form.Province,
form.PostalCode,
form.Country,
form.Tags))
}

View File

@ -16,6 +16,8 @@ import (
"time"
)
var tagsRegex = regexp.MustCompile("[^a-z0-9-]+")
type Attribute struct {
Key, Val string
}
@ -209,6 +211,49 @@ func mustGetCountryOptions(ctx context.Context, conn *Conn, locale *Locale) []*S
return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", locale.Language)
}
type TagsField struct {
Name string
Label string
Tags []string
Attributes []template.HTMLAttr
Required bool
Errors []error
}
func (field *TagsField) Value() (driver.Value, error) {
if field.Tags == nil {
return []string{}, nil
}
return field.Tags, nil
}
func (field *TagsField) Scan(value interface{}) error {
if value == nil {
return nil
}
if str, ok := value.(string); ok {
if array, err := pgtype.ParseUntypedTextArray(str); err == nil {
for _, element := range array.Elements {
field.Tags = append(field.Tags, element)
}
return nil
}
}
field.Tags = append(field.Tags, fmt.Sprintf("%v", value))
return nil
}
func (field *TagsField) FillValue(r *http.Request) {
field.Tags = strings.Split(tagsRegex.ReplaceAllString(r.FormValue(field.Name), ","), ",")
if len(field.Tags) == 1 && len(field.Tags[0]) == 0 {
field.Tags = []string{}
}
}
func (field *TagsField) String() string {
return strings.Join(field.Tags, ",")
}
type FormValidator struct {
Valid bool
}

View File

@ -14,7 +14,6 @@ import (
"net/http"
"os"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
@ -365,7 +364,7 @@ func HandleAddInvoice(w http.ResponseWriter, r *http.Request, _ httprouter.Param
mustRenderNewInvoiceForm(w, r, form)
return
}
slug := conn.MustGetText(r.Context(), "", "select add_invoice($1, $2, $3, $4, $5, $6, $7, $8)", company.Id, form.Number, form.Date, form.Customer, form.Notes, form.PaymentMethod, form.SplitTags(), NewInvoiceProductArray(form.Products))
slug := conn.MustGetText(r.Context(), "", "select add_invoice($1, $2, $3, $4, $5, $6, $7, $8)", company.Id, form.Number, form.Date, form.Customer, form.Notes, form.PaymentMethod, form.Tags, NewInvoiceProductArray(form.Products))
http.Redirect(w, r, companyURI(company, "/invoices/"+slug), http.StatusSeeOther)
}
@ -438,7 +437,7 @@ type invoiceForm struct {
Date *InputField
Notes *InputField
PaymentMethod *SelectField
Tags *InputField
Tags *TagsField
Products []*invoiceProductForm
}
@ -475,10 +474,9 @@ func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Label: pgettext("input", "Notes", locale),
Type: "textarea",
},
Tags: &InputField{
Tags: &TagsField{
Name: "tags",
Label: pgettext("input", "Tags", locale),
Type: "text",
},
PaymentMethod: &SelectField{
Name: "payment_method",
@ -607,15 +605,6 @@ func mustGetTaxOptions(ctx context.Context, conn *Conn, company *Company) []*Sel
return MustGetGroupedOptions(ctx, conn, "select tax_id::text, tax.name, tax_class.name from tax join tax_class using (tax_class_id) where tax.company_id = $1 order by tax_class.name, tax.name", company.Id)
}
func (form *invoiceForm) SplitTags() []string {
reg := regexp.MustCompile("[^a-z0-9-]+")
tags := strings.Split(reg.ReplaceAllString(form.Tags.Val, ","), ",")
if len(tags) == 1 && len(tags[0]) == 0 {
return []string{}
}
return tags
}
type invoiceProductForm struct {
locale *Locale
company *Company
@ -759,7 +748,7 @@ func HandleUpdateInvoice(w http.ResponseWriter, r *http.Request, params httprout
mustRenderEditInvoiceForm(w, r, slug, form)
return
}
slug = conn.MustGetText(r.Context(), "", "select edit_invoice($1, $2, $3, $4, $5, $6, $7)", slug, form.InvoiceStatus, form.Customer, form.Notes, form.PaymentMethod, form.SplitTags(), EditedInvoiceProductArray(form.Products))
slug = conn.MustGetText(r.Context(), "", "select edit_invoice($1, $2, $3, $4, $5, $6, $7)", slug, form.InvoiceStatus, form.Customer, form.Notes, form.PaymentMethod, form.Tags, EditedInvoiceProductArray(form.Products))
if slug == "" {
http.NotFound(w, r)
return

7
revert/add_contact.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:add_contact from pg
begin;
drop function if exists numerus.add_contact(integer, text, text, text, text, numerus.email, uri, text, text, text, text, numerus.country_code, numerus.tag_name[]);
commit;

7
revert/contact_tag.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:contact_tag from pg
begin;
drop table if exists numerus.contact_tag;
commit;

7
revert/edit_contact.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:edit_contact from pg
begin;
drop function if exists numerus.edit_contact(uuid, text, text, text, text, numerus.email, uri, text, text, text, text, numerus.country_code, numerus.tag_name[]);
commit;

7
revert/tag_contact.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:tag_contact from pg
begin;
drop function if exists numerus.tag_contact(integer, integer, numerus.tag_name[]);
commit;

7
revert/tag_invoice.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:tag_invoice from pg
begin;
drop function if exists numerus.tag_invoice(integer, integer, numerus.tag_name[]);
commit;

7
revert/tag_relation.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:tag_relation from pg
begin;
drop function if exists numerus.tag_relation(regclass, name, integer, integer, numerus.tag_name[]);
commit;

View File

@ -41,7 +41,7 @@ company_default_payment_method [schema_numerus company payment_method] 2023-03-0
tax_class [schema_numerus company] 2023-02-28T10:13:14Z jordi fita mas <jordi@tandem.blog> # Add the relation for tax classes
tax_rate [schema_numerus] 2023-01-28T11:33:39Z jordi fita mas <jordi@tandem.blog> # Add domain for tax rates
tax [schema_numerus company tax_rate tax_class] 2023-01-28T11:45:47Z jordi fita mas <jordi@tandem.blog> # Add relation for taxes
contact [schema_numerus company extension_vat email extension_pg_libphonenumber extension_uri currency_code currency country_code country] 2023-01-29T12:59:18Z jordi fita mas <jordi@tandem.blog> # Add the relation for contacts
contact [schema_numerus company extension_vat email extension_pg_libphonenumber extension_uri country_code country] 2023-01-29T12:59:18Z jordi fita mas <jordi@tandem.blog> # Add the relation for contacts
product [schema_numerus company tax] 2023-02-04T09:17:24Z jordi fita mas <jordi@tandem.blog> # Add relation for products
parse_price [schema_public] 2023-02-05T11:04:54Z jordi fita mas <jordi@tandem.blog> # Add function to convert from price to cents
to_price [schema_numerus] 2023-02-05T11:46:31Z jordi fita mas <jordi@tandem.blog> # Add function to format cents to prices
@ -61,11 +61,17 @@ next_invoice_number [schema_numerus invoice_number_counter] 2023-02-17T13:21:48Z
tag_name [schema_numerus] 2023-03-10T11:06:11Z jordi fita mas <jordi@tandem.blog> # Add domain for tag names
tag [schema_numerus tag_name] 2023-03-10T11:04:24Z jordi fita mas <jordi@tandem.blog> # Add relation for tags
invoice_tag [schema_numerus tag invoice] 2023-03-10T11:37:43Z jordi fita mas <jordi@tandem.blog> # Add relation for invoice tag
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_tag] 2023-02-16T21:12:46Z jordi fita mas <jordi@tandem.blog> # Add function to create new invoices
tag_relation [schema_numerus tag tag_name] 2023-03-25T17:40:52Z jordi fita mas <jordi@tandem.blog> # Add function to tag “relations”
tag_invoice [schema_numerus tag_name tag_relation invoice_tag] 2023-03-25T18:04:02Z jordi fita mas <jordi@tandem.blog> # 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 <jordi@tandem.blog> # Add function to create new invoices
invoice_tax_amount [schema_numerus invoice_product invoice_product_tax] 2023-02-22T12:08:35Z jordi fita mas <jordi@tandem.blog> # Add view for invoice tax amount
invoice_product_amount [schema_numerus invoice_product invoice_product_tax] 2023-03-01T11:18:05Z jordi fita mas <jordi@tandem.blog> # Add view for invoice product subtotal and total
invoice_amount [schema_numerus invoice_product invoice_product_amount] 2023-02-22T12:58:46Z jordi fita mas <jordi@tandem.blog> # Add view to compute subtotal and total for invoices
new_invoice_amount [schema_numerus] 2023-02-23T12:08:25Z jordi fita mas <jordi@tandem.blog> # Add type to return when computing new invoice amounts
compute_new_invoice_amount [schema_numerus company currency tax new_invoice_product new_invoice_amount] 2023-02-23T12:20:13Z jordi fita mas <jordi@tandem.blog> # Add function to compute the subtotal, taxes, and total amounts for a new invoice
edited_invoice_product [schema_numerus discount_rate] 2023-03-11T19:22:24Z jordi fita mas <jordi@tandem.blog> # Add typo for passing products to edited invoices
edit_invoice [schema_numerus invoice currency parse_price edited_invoice_product tax invoice_product invoice_product_tax tag_name tag invoice_tag] 2023-03-11T18:30:50Z jordi fita mas <jordi@tandem.blog> # Add function to edit invoices
edit_invoice [schema_numerus invoice currency parse_price edited_invoice_product tax invoice_product invoice_product_tax tag_name tag_invoice] 2023-03-11T18:30:50Z jordi fita mas <jordi@tandem.blog> # Add function to edit invoices
contact_tag [schema_numerus tag contact] 2023-03-24T22:20:51Z jordi fita mas <jordi@tandem.blog> # Add relation for contact tag
tag_contact [schema_numerus tag_name tag_relation contact_tag] 2023-03-25T22:16:42Z jordi fita mas <jordi@tandem.blog> # Add function to tag contacts
add_contact [schema_numerus extension_vat email extension_pg_libphonenumber extension_uri country_code tag_name contact] 2023-03-25T22:32:37Z jordi fita mas <jordi@tandem.blog> # Add function to create new contacts
edit_contact [schema_numerus email extension_uri country_code tag_name contact tag_contact extension_vat extension_pg_libphonenumber] 2023-03-25T23:20:27Z jordi fita mas <jordi@tandem.blog> # Add function to edit contacts

96
test/add_contact.sql Normal file
View File

@ -0,0 +1,96 @@
-- Test add_contact
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(16);
set search_path to auth, numerus, public;
select has_function('numerus', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]']);
select function_lang_is('numerus', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'plpgsql');
select function_returns('numerus', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'uuid');
select isnt_definer('numerus', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]']);
select volatility_is('numerus', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'volatile');
select function_privs_are('numerus', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'guest', array []::text[]);
select function_privs_are('numerus', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'authenticator', array []::text[]);
set client_min_messages to warning;
truncate contact_tag cascade;
truncate tag cascade;
truncate contact 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)
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 222)
;
insert into payment_method (payment_method_id, company_id, name, instructions)
values (111, 1, 'cash', 'cash')
, (222, 2, 'cash', 'cash')
;
set constraints "company_default_payment_method_id_fkey" immediate;
select lives_ok(
$$ select add_contact(1, 'Contact 2.1', '40404040D', 'Trade Contact 2.1', '777-777-777', 'c@c', 'https://c', 'Fake St., 123', 'City 2.1', 'Province 2.1', '17486', 'ES', '{tag1,tag2}') $$,
'Should be able to insert a contact for the first company with two tags'
);
select lives_ok(
$$ select add_contact(1, 'Contact 2.2', '41414141L', 'Trade Contact 2.2', '888-888-888', 'd@d', 'https://d', 'Another Fake St., 123', 'City 2.2', 'Province 2.2', '17487', 'ES', '{}') $$,
'Should be able to insert a second contact for the first company with no tag'
);
select lives_ok(
$$ select add_contact(2, 'Contact 4.1', '42424242Y', '', '999-999-999', 'e@e', '', 'Yet Another Fake St., 123', 'City 4.1', 'Province 4.1', '17488', 'ES', '{tag2}') $$,
'Should be able to insert a contact for the second company with a tag'
);
select lives_ok(
$$ select add_contact(1, 'Contact 2.3', '43434343Q', '', '000-000-000', 'f@f', '', 'The Last Fake St., 123', '', '', '', 'ES', '{tag2}') $$,
'Should be able to insert another contact with a repeated tag'
);
select bag_eq(
$$ select company_id, business_name, vatin::text, trade_name, phone::text, email::text, web::text, address, city, province, postal_code, country_code::text, created_at from contact $$,
$$ values (1, 'Contact 2.1', 'ES40404040D', 'Trade Contact 2.1', '+34 777 77 77 77', 'c@c', 'https://c', 'Fake St., 123', 'City 2.1', 'Province 2.1', '17486', 'ES', CURRENT_TIMESTAMP)
, (1, 'Contact 2.2', 'ES41414141L', 'Trade Contact 2.2', '+34 888 88 88 88', 'd@d', 'https://d', 'Another Fake St., 123', 'City 2.2', 'Province 2.2', '17487', 'ES', CURRENT_TIMESTAMP)
, (2, 'Contact 4.1', 'ES42424242Y', '', '+34 999 99 99 99', 'e@e', '', 'Yet Another Fake St., 123', 'City 4.1', 'Province 4.1', '17488', 'ES', CURRENT_TIMESTAMP)
, (1, 'Contact 2.3', 'ES43434343Q', '', '+34 000000000', 'f@f', '', 'The Last Fake St., 123', '', '', '', 'ES', CURRENT_TIMESTAMP)
$$,
'Should have created all contacts'
);
select bag_eq(
$$ select company_id, name from tag $$,
$$ values (1, 'tag1')
, (1, 'tag2')
, (2, 'tag2')
$$,
'Should have added all new tags once'
);
select bag_eq(
$$ select business_name, tag.name from contact_tag join contact using (contact_id) join tag using (tag_id) $$,
$$ values ('Contact 2.1', 'tag1')
, ('Contact 2.1', 'tag2')
, ('Contact 4.1', 'tag2')
, ('Contact 2.3', 'tag2')
$$,
'Should have assigned the tags to contacts'
);
select *
from finish();
rollback;

130
test/contact_tag.sql Normal file
View File

@ -0,0 +1,130 @@
-- Test contact_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('contact_tag');
select has_pk('contact_tag' );
select col_is_pk('contact_tag', array['contact_id', 'tag_id']);
select table_privs_are('contact_tag', 'guest', array []::text[]);
select table_privs_are('contact_tag', 'invoicer', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
select table_privs_are('contact_tag', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
select table_privs_are('contact_tag', 'authenticator', array []::text[]);
select has_column('contact_tag', 'contact_id');
select col_is_fk('contact_tag', 'contact_id');
select fk_ok('contact_tag', 'contact_id', 'contact', 'contact_id');
select col_type_is('contact_tag', 'contact_id', 'integer');
select col_not_null('contact_tag', 'contact_id');
select col_hasnt_default('contact_tag', 'contact_id');
select has_column('contact_tag', 'tag_id');
select col_is_fk('contact_tag', 'tag_id');
select fk_ok('contact_tag', 'tag_id', 'tag', 'tag_id');
select col_type_is('contact_tag', 'tag_id', 'integer');
select col_not_null('contact_tag', 'tag_id');
select col_hasnt_default('contact_tag', 'tag_id');
set client_min_messages to warning;
truncate contact_tag cascade;
truncate contact cascade;
truncate tag cascade;
truncate contact 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 contact (contact_id, company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code)
values (6, 2, 'Contact 1', 'XX555', '', '777-777-777', 'c@c', '', '', '', '', '', 'ES')
, (8, 4, 'Contact 2', 'XX666', '', '888-888-888', 'd@d', '', '', '', '', '', 'ES')
;
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 contact_tag (contact_id, tag_id)
values (6, 14)
, (6, 15)
, (8, 18)
;
prepare contact_tag_data as
select contact_id, tag_id
from contact_tag
;
set role invoicer;
select is_empty('contact_tag_data', 'Should show no data when cookie is not set yet');
reset role;
select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog');
select bag_eq(
'contact_tag_data',
$$ values ( 6, 14 )
, ( 6, 15 )
$$,
'Should only list contact tags of the companies where demo@tandem.blog is user of'
);
reset role;
select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog');
select bag_eq(
'contact_tag_data',
$$ values ( 8, 18 )
$$,
'Should only list contact tags of the companies where admin@tandem.blog is user of'
);
reset role;
select set_cookie('not-a-cookie');
select throws_ok(
'contact_tag_data',
'42501', 'permission denied for table contact_tag',
'Should not allow select to guest users'
);
select *
from finish();
rollback;

101
test/edit_contact.sql Normal file
View File

@ -0,0 +1,101 @@
-- Test edit_contact
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(14);
set search_path to auth, numerus, public;
select has_function('numerus', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]']);
select function_lang_is('numerus', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'plpgsql');
select function_returns('numerus', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'uuid');
select isnt_definer('numerus', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]']);
select volatility_is('numerus', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'volatile');
select function_privs_are('numerus', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'guest', array []::text[]);
select function_privs_are('numerus', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'email', 'uri', 'text', 'text', 'text', 'text', 'country_code', 'tag_name[]'], 'authenticator', array []::text[]);
set client_min_messages to warning;
truncate contact_tag cascade;
truncate tag cascade;
truncate contact 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')
;
-- edit_contact uses the sequence and sometimes it would confict
alter sequence tag_tag_id_seq restart with 15;
insert into contact (contact_id, company_id, slug, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code)
values (12, 1, '7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'Contact 1', 'XX555', '', '777-777-777', 'c@c', '', '', '', '', '', 'ES')
, (13, 1, 'b57b980b-247b-4be4-a0b7-03a7819c53ae', 'Contact 2', 'XX666', '', '888-888-888', 'd@d', '', '', '', '', '', 'ES')
;
insert into contact_tag (contact_id, tag_id)
values (12, 10)
, (13, 11)
;
select lives_ok(
$$ select edit_contact('7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'Contact 2.1', '40404040D', 'Trade Contact 2.1', '999-999-999', 'c1@c1', 'https://c', 'Fake St., 123', 'City 2.1', 'Province 2.1', '19486', 'ES', array['tag1']) $$,
'Should be able to edit the first contact'
);
select lives_ok(
$$ select edit_contact('b57b980b-247b-4be4-a0b7-03a7819c53ae', 'Contact 2.2', '41414141L', 'Trade Contact 2.2', '111-111-111', 'd2@d2', 'https://d', 'Another Fake St., 123', 'City 2.2', 'Province 2.2', '17417', 'ES', array['tag1', 'tag3']) $$,
'Should be able to edit the second contact'
);
select bag_eq(
$$ select company_id, business_name, vatin::text, trade_name, phone::text, email::text, web::text, address, city, province, postal_code, country_code::text, created_at from contact $$,
$$ values (1, 'Contact 2.1', 'ES40404040D', 'Trade Contact 2.1', '+34 999 99 99 99', 'c1@c1', 'https://c', 'Fake St., 123', 'City 2.1', 'Province 2.1', '19486', 'ES', CURRENT_TIMESTAMP)
, (1, 'Contact 2.2', 'ES41414141L', 'Trade Contact 2.2', '+34 111111111', 'd2@d2', 'https://d', 'Another Fake St., 123', 'City 2.2', 'Province 2.2', '17417', 'ES', CURRENT_TIMESTAMP)
$$,
'Should have updated all contacts'
);
select bag_eq(
$$ select company_id, name from tag $$,
$$ values (1, 'tag1')
, (1, 'tag2')
, (1, 'tag3')
$$,
'Should have added all new tags'
);
select bag_eq(
$$ select business_name, tag.name from contact_tag join contact using (contact_id) join tag using (tag_id) $$,
$$ values ('Contact 2.1', 'tag1')
, ('Contact 2.2', 'tag1')
, ('Contact 2.2', 'tag3')
$$,
'Should have assigned the tags to contacts'
);
select *
from finish();
rollback;

124
test/tag_contact.sql Normal file
View File

@ -0,0 +1,124 @@
-- Test tag_contact
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_contact', array ['integer', 'integer', 'tag_name[]']);
select function_lang_is('numerus', 'tag_contact', array ['integer', 'integer', 'tag_name[]'], 'sql');
select function_returns('numerus', 'tag_contact', array ['integer', 'integer', 'tag_name[]'], 'void');
select isnt_definer('numerus', 'tag_contact', array ['integer', 'integer', 'tag_name[]']);
select volatility_is('numerus', 'tag_contact', array ['integer', 'integer', 'tag_name[]'], 'volatile');
select function_privs_are('numerus', 'tag_contact', array ['integer', 'integer', 'tag_name[]'], 'guest', array []::text[]);
select function_privs_are('numerus', 'tag_contact', array ['integer', 'integer', 'tag_name[]'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'tag_contact', array ['integer', 'integer', 'tag_name[]'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'tag_contact', array ['integer', 'integer', 'tag_name[]'], 'authenticator', array []::text[]);
set client_min_messages to warning;
truncate contact_tag cascade;
truncate tag cascade;
truncate contact 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_contact uses the sequence and sometimes it would confict
alter sequence tag_tag_id_seq restart with 15;
insert into contact (contact_id, company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code)
values (12, 1, 'Contact 2.1', 'XX555', '', '777-777-777', 'c@c', '', '', '', '', '', 'ES')
, (13, 1, 'Contact 2.2', 'XX666', '', '888-888-888', 'd@d', '', '', '', '', '', 'ES')
;
insert into contact_tag (contact_id, tag_id)
values (12, 10)
, (13, 11)
;
prepare current_tags as
select contact_id, tag.name
from contact
join contact_tag using (contact_id)
join tag using (tag_id);
select lives_ok(
$$ select tag_contact(1, 12, array['tag1']) $$,
'Should be able to keep the same tags to the contact'
);
select bag_eq(
'current_tags',
$$ values (12, 'tag1')
, (13, 'tag2')
$$,
'Should not have changed any contact tag'
);
select lives_ok(
$$ select tag_contact(1, 12, array['tag1', 'tag2']) $$,
'Should be able to add tag2 contact'
);
select bag_eq(
'current_tags',
$$ values (12, 'tag1')
, (12, 'tag2')
, (13, 'tag2')
$$,
'Should have added tag2 to contact'
);
select lives_ok(
$$ select tag_contact(1, 13, array['tag3']) $$,
'Should be able to replace all tags of an contact with a new one'
);
select bag_eq(
'current_tags',
$$ values (12, 'tag1')
, (12, 'tag2')
, (13, 'tag3')
$$,
'Should have set tag3 to contact'
);
select lives_ok(
$$ select tag_contact(1, 12, array[]::tag_name[]) $$,
'Should be able to remove all tags from an contact'
);
select bag_eq(
'current_tags',
$$ values (13, 'tag3')
$$,
'Should have remove all tags from contact'
);
select *
from finish();
rollback;

130
test/tag_invoice.sql Normal file
View File

@ -0,0 +1,130 @@
-- Test tag_invoice
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_invoice', array ['integer', 'integer', 'tag_name[]']);
select function_lang_is('numerus', 'tag_invoice', array ['integer', 'integer', 'tag_name[]'], 'sql');
select function_returns('numerus', 'tag_invoice', array ['integer', 'integer', 'tag_name[]'], 'void');
select isnt_definer('numerus', 'tag_invoice', array ['integer', 'integer', 'tag_name[]']);
select volatility_is('numerus', 'tag_invoice', array ['integer', 'integer', 'tag_name[]'], 'volatile');
select function_privs_are('numerus', 'tag_invoice', array ['integer', 'integer', 'tag_name[]'], 'guest', array []::text[]);
select function_privs_are('numerus', 'tag_invoice', array ['integer', 'integer', 'tag_name[]'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'tag_invoice', array ['integer', 'integer', 'tag_name[]'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'tag_invoice', array ['integer', 'integer', 'tag_name[]'], 'authenticator', array []::text[]);
set client_min_messages to warning;
truncate invoice_tag cascade;
truncate tag cascade;
truncate invoice cascade;
truncate contact 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_invoice uses the sequence and sometimes it would confict
alter sequence tag_tag_id_seq restart with 15;
insert into contact (contact_id, company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code)
values (12, 1, 'Contact 2.1', 'XX555', '', '777-777-777', 'c@c', '', '', '', '', '', 'ES')
, (13, 1, 'Contact 2.2', 'XX666', '', '888-888-888', 'd@d', '', '', '', '', '', 'ES')
;
insert into invoice (invoice_id, company_id, slug, invoice_number, invoice_date, contact_id, payment_method_id, currency_code)
values (15, 1, '7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'INV1', '2023-03-10', 12, 111, 'EUR')
, (16, 1, 'b57b980b-247b-4be4-a0b7-03a7819c53ae', 'INV2', '2023-03-09', 13, 111, 'EUR')
;
insert into invoice_tag (invoice_id, tag_id)
values (15, 10)
, (16, 11)
;
prepare current_tags as
select invoice_id, tag.name
from invoice
join invoice_tag using (invoice_id)
join tag using (tag_id);
select lives_ok(
$$ select tag_invoice(1, 15, array['tag1']) $$,
'Should be able to keep the same tags to the invoice'
);
select bag_eq(
'current_tags',
$$ values (15, 'tag1')
, (16, 'tag2')
$$,
'Should not have changed any invoice tag'
);
select lives_ok(
$$ select tag_invoice(1, 15, array['tag1', 'tag2']) $$,
'Should be able to add tag2 invoice'
);
select bag_eq(
'current_tags',
$$ values (15, 'tag1')
, (15, 'tag2')
, (16, 'tag2')
$$,
'Should have added tag2 to invoice'
);
select lives_ok(
$$ select tag_invoice(1, 16, array['tag3']) $$,
'Should be able to replace all tags of an invoice with a new one'
);
select bag_eq(
'current_tags',
$$ values (15, 'tag1')
, (15, 'tag2')
, (16, 'tag3')
$$,
'Should have set tag3 to invoice'
);
select lives_ok(
$$ select tag_invoice(1, 15, array[]::tag_name[]) $$,
'Should be able to remove all tags from an invoice'
);
select bag_eq(
'current_tags',
$$ values (16, 'tag3')
$$,
'Should have remove all tags from invoice'
);
select *
from finish();
rollback;

25
test/tag_relation.sql Normal file
View File

@ -0,0 +1,25 @@
-- Test tag_relation
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
set search_path to numerus, public;
select plan(9);
select has_function('numerus', 'tag_relation', array ['regclass', 'name', 'integer', 'integer', 'tag_name[]']);
select function_lang_is('numerus', 'tag_relation', array ['regclass', 'name', 'integer', 'integer', 'tag_name[]'], 'plpgsql');
select function_returns('numerus', 'tag_relation', array ['regclass', 'name', 'integer', 'integer', 'tag_name[]'], 'void');
select isnt_definer('numerus', 'tag_relation', array ['regclass', 'name', 'integer', 'integer', 'tag_name[]']);
select volatility_is('numerus', 'tag_relation', array ['regclass', 'name', 'integer', 'integer', 'tag_name[]'], 'volatile');
select function_privs_are('numerus', 'tag_relation', array ['regclass', 'name', 'integer', 'integer', 'tag_name[]'], 'guest', array []::text[]);
select function_privs_are('numerus', 'tag_relation', array ['regclass', 'name', 'integer', 'integer', 'tag_name[]'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'tag_relation', array ['regclass', 'name', 'integer', 'integer', 'tag_name[]'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'tag_relation', array ['regclass', 'name', 'integer', 'integer', 'tag_name[]'], 'authenticator', array []::text[]);
select *
from finish();
rollback;

7
verify/add_contact.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify numerus:add_contact on pg
begin;
select has_function_privilege('numerus.add_contact(integer, text, text, text, text, numerus.email, uri, text, text, text, text, numerus.country_code, numerus.tag_name[])', 'execute');
rollback;

13
verify/contact_tag.sql Normal file
View File

@ -0,0 +1,13 @@
-- Verify numerus:contact_tag on pg
begin;
select contact_id
, tag_id
from numerus.contact_tag
where false;
select 1 / count(*) from pg_class where oid = 'numerus.contact_tag'::regclass and relrowsecurity;
select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'numerus.contact_tag'::regclass;
rollback;

7
verify/edit_contact.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify numerus:edit_contact on pg
begin;
select has_function_privilege('numerus.edit_contact(uuid, text, text, text, text, numerus.email, uri, text, text, text, text, numerus.country_code, numerus.tag_name[])', 'execute');
rollback;

7
verify/tag_contact.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify numerus:tag_contact on pg
begin;
select has_function_privilege('numerus.tag_contact(integer, integer, numerus.tag_name[])', 'execute');
rollback;

7
verify/tag_invoice.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify numerus:tag_invoice on pg
begin;
select has_function_privilege('numerus.tag_invoice(integer, integer, numerus.tag_name[])', 'execute');
rollback;

7
verify/tag_relation.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify numerus:tag_relation on pg
begin;
select has_function_privilege('numerus.tag_relation(regclass, name, integer, integer, numerus.tag_name[])', 'execute');
rollback;

View File

@ -702,6 +702,60 @@ tr.htmx-swapping td {
transition: opacity 1s ease-out;
}
/* Snackbar */
[x-cloak] {
display: none !important;
}
div[x-data="snackbar"] div[role="alert"] {
cursor: pointer;
background-color: var(--numerus--color--black);
color: var(--numerus--color--white);
padding: 2rem;
min-width: 28.8rem;
max-width: 56.8rem;
border-radius: 2px;
position: fixed;
translate: -50%;
left: 50%;
bottom: 0;
}
div[x-data="snackbar"] div[role="alert"].enter, div[x-data="snackbar"] div[role="alert"].leave {
transition: transform;
transition-duration: 300ms;
}
div[x-data="snackbar"] div[role="alert"].enter {
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
}
div[x-data="snackbar"] div[role="alert"].leave {
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);;
}
div[x-data="snackbar"] div[role="alert"].enter.start, div[x-data="snackbar"] div[role="alert"].leave.end {
transform: translateY(100%);
}
div[x-data="snackbar"] div[role="alert"].enter p {
transition: opacity;
transition-delay: 150ms;
transition-duration: 300ms;
}
div[x-data="snackbar"] div[role="alert"].enter.start p {
opacity: 0;
}
div[x-data="snackbar"] div[role="alert"].enter.end p {
opacity: 1;
}
div[x-data="snackbar"] div[role="alert"].enter.end, div[x-data="snackbar"] div[role="alert"].leave.start {
transform: translateY(0);
}
/* Remix Icon */
@font-face {

View File

@ -426,3 +426,48 @@ htmx.on('closeModal', () => {
openDialog.close();
openDialog.remove();
});
htmx.on(document, 'alpine:init', () => {
Alpine.data('snackbar', () => ({
show: false, toast: "", toasts: [], timeoutId: null, init() {
htmx.on('htmx:error', (error) => {
this.showError(error.detail.errorInfo.error);
});
},
showError(message) {
this.toasts.push(message);
this.popUp();
},
popUp() {
if (this.toasts.length === 0) {
return;
}
if (this.show) {
this.dismiss();
return;
}
if (this.toast !== "") {
// It will show after remove calls popUp again.
return;
}
this.toast = this.toasts[0];
this.show = true;
this.timeoutId = setTimeout(this.dismiss.bind(this), 4000);
},
dismiss() {
if (!this.show) {
// already dismissed
return;
}
this.show = false;
clearTimeout(this.timeoutId);
this.timeoutId = setTimeout(this.remove.bind(this), 350);
},
remove() {
clearTimeout(this.timeoutId);
this.toasts.splice(0, 1);
this.toast = "";
this.popUp();
},
}));
});

View File

@ -7,6 +7,7 @@
<link rel="stylesheet" type="text/css" media="screen" href="/static/numerus.css">
<script src="/static/htmx@1.8.6.min.js"></script>
<script type="module" src="/static/numerus.js"></script>
<script defer src="/static/alpinejs@3.12.0.min.js"></script>
</head>
<body>
<header>
@ -53,4 +54,18 @@
{{- template "content" . }}
</main>
</body>
<div x-data="snackbar">
<div x-show="show"
@click="dismiss"
x-cloak
x-transition:enter="enter"
x-transition:enter-start="start"
x-transition:enter-end="end"
x-transition:leave="leave"
x-transition:leave-start="start"
x-transition:leave-end="end"
role="alert">
<p x-text="toast"></p>
</div>
</div>
</html>

View File

@ -34,6 +34,7 @@
{{ template "input-field" .Province }}
{{ template "input-field" .PostalCode }}
{{ template "select-field" .Country | addSelectAttr `class="width-fixed"` }}
{{ template "tags-field" .Tags }}
{{ end }}
<fieldset>

View File

@ -26,6 +26,7 @@
<th>{{( pgettext "Customer" "title" )}}</th>
<th>{{( pgettext "Email" "title" )}}</th>
<th>{{( pgettext "Phone" "title" )}}</th>
<th>{{( pgettext "Tags" "title" )}}</th>
</tr>
</thead>
<tbody data-hx-push-url="false" data-hx-swap="beforeend">
@ -36,11 +37,17 @@
<td><a href="{{ companyURI "/contacts/"}}{{ .Slug }}" data-hx-boost="true">{{ .Name }}</a></td>
<td><a href="mailto:{{ .Email }}">{{ .Email }}</a></td>
<td><a href="tel:{{ .Phone }}">{{ .Phone }}</a></td>
<td>
{{- range $index, $tag := .Tags }}
{{- if gt $index 0 }}, {{ end -}}
<a href="?tag={{ . }}">{{ . }}</a>
{{- end }}
</td>
</tr>
{{- end }}
{{ else }}
<tr>
<td colspan="4">{{( gettext "No contacts added yet." )}}</td>
<td colspan="5">{{( gettext "No contacts added yet." )}}</td>
</tr>
{{ end }}
</tbody>

View File

@ -30,6 +30,7 @@
{{ template "input-field" .Province }}
{{ template "input-field" .PostalCode }}
{{ template "select-field" .Country | addSelectAttr `class="width-fixed"` }}
{{ template "tags-field" .Tags }}
<fieldset>
<button class="primary" type="submit">{{( pgettext "New contact" "action" )}}</button>

View File

@ -34,7 +34,7 @@
<div class="input {{ if .Errors }}has-errors{{ end }}" is="numerus-tags">
<input type="text" name="{{ .Name }}" id="{{ .Name }}-field"
{{- range $attribute := .Attributes }} {{$attribute}} {{ end }}
{{ if .Required }}required="required"{{ end }} value="{{ .Val }}" placeholder="{{ .Label }}">
{{ if .Required }}required="required"{{ end }} value="{{ .String }}" placeholder="{{ .Label }}">
<label for="{{ .Name }}-field">{{ .Label }}</label>
{{- if .Errors }}
<ul>