Compare commits

..

No commits in common. "4131602fa35322a8bf9a76e622c6a8e7569ab675" and "41ce5af2edd62c88af0038efacdcefbb2a63a8be" have entirely different histories.

39 changed files with 70 additions and 1163 deletions

View File

@ -41,14 +41,15 @@ values (1, 1, 'Retenció 15 %', -0.15)
, (1, 2, 'IVA 4 %', 0.04) , (1, 2, 'IVA 4 %', 0.04)
; ;
alter sequence tag_tag_id_seq restart;
alter sequence contact_contact_id_seq restart; alter sequence contact_contact_id_seq restart;
select add_contact (1, 'Melcior', '1', 'Rei Blanc', '0732621', 'melcio@reismags.cat', '', 'C/ Principal, 1', 'Shiraz', 'Fars', '1', 'IR', array['pesebre', 'mag']); insert into contact (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code)
select add_contact (1, 'Gaspar', '2', 'Rei Ros', '111', 'gaspar@reismags.cat', '', 'C/ Principal, 2', 'Nova Delhi', 'Delhi', '2', 'IN', array['pesebre', 'mag']); values (1, 'Melcior', 'IR1', 'Rei Blanc', parse_packed_phone_number('0732621', 'IR'), 'melcio@reismags.cat', '', 'C/ Principal, 1', 'Shiraz', 'Fars', '1', 'IR')
select add_contact (1, 'Baltasar', '3', 'Rei Negre', '1-111-111', 'baltasar@reismags.cat', '', 'C/ Principal, 3', 'Sanaa', 'Sanaa', '3', 'YE', array['pesebre', 'mag']); , (1, 'Gaspar', 'IN2', 'Rei Ros', parse_packed_phone_number('111', 'IN'), 'gaspar@reismags.cat', '', 'C/ Principal, 2', 'Nova Delhi', 'Delhi', '2', 'IN')
select add_contact (1, 'Caganera', '41414141L', '', '222 222 222', 'caganera@pesebre.cat', '', 'C/ De lHort, 4', 'Olot', 'Girona', '17800', 'ES', array['pesebre', 'persona']); , (1, 'Baltasar', 'YE3', 'Rei Negre', parse_packed_phone_number('1-111-111', 'YE'), 'baltasar@reismags.cat', '', 'C/ Principal, 3', 'Sanaa', 'Sanaa', '3', 'YE')
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']); , (1, 'Caganera', 'ES41414141L', '', parse_packed_phone_number('222 222 222', 'ES'), 'caganera@pesebre.cat', '', 'C/ De lHort, 4', 'Olot', 'Girona', '17800', 'ES')
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']); , (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')
;
alter sequence product_product_id_seq restart; 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]); 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]);
@ -59,6 +60,7 @@ 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, '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]); 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_invoice_id_seq restart;
alter sequence invoice_product_invoice_product_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})"}'); 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})"}');

View File

@ -1,38 +0,0 @@
-- 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,7 +10,8 @@
-- requires: invoice_product_tax -- requires: invoice_product_tax
-- requires: next_invoice_number -- requires: next_invoice_number
-- requires: tag_name -- requires: tag_name
-- requires: tag_invoice -- requires: tag
-- requires: invoice_tag
begin; begin;
@ -63,7 +64,19 @@ begin
join unnest(product.tax) as ptax(tax_id) using (tax_id); join unnest(product.tax) as ptax(tax_id) using (tax_id);
end loop; end loop;
perform tag_invoice(company, iid, tags); 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;
return pslug; return pslug;
end; end;

View File

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

View File

@ -1,31 +0,0 @@
-- 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;

View File

@ -1,55 +0,0 @@
-- 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,7 +8,8 @@
-- requires: invoice_product -- requires: invoice_product
-- requires: invoice_product_tax -- requires: invoice_product_tax
-- requires: tag_name -- requires: tag_name
-- requires: tag_invoice -- requires: tag
-- requires: invoice_tag
begin; begin;
@ -89,7 +90,21 @@ begin
delete from invoice_product where invoice_product_id = any(products_to_delete); delete from invoice_product where invoice_product_id = any(products_to_delete);
end if; end if;
perform tag_invoice(company, iid, tags); 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;
return invoice_slug; return invoice_slug;
end; end;

View File

@ -1,22 +0,0 @@
-- 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;

View File

@ -1,21 +0,0 @@
-- 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;

View File

@ -1,32 +0,0 @@
-- 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,7 +12,6 @@ type ContactEntry struct {
Name string Name string
Email string Email string
Phone string Phone string
Tags []string
} }
type ContactsIndexPage struct { type ContactsIndexPage struct {
@ -22,9 +21,8 @@ type ContactsIndexPage struct {
func IndexContacts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func IndexContacts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r) conn := getConn(r)
company := mustGetCompany(r) company := mustGetCompany(r)
tag := r.URL.Query().Get("tag")
page := &ContactsIndexPage{ page := &ContactsIndexPage{
Contacts: mustCollectContactEntries(r.Context(), conn, company, tag), Contacts: mustGetContactEntries(r.Context(), conn, company),
} }
mustRenderMainTemplate(w, r, "contacts/index.gohtml", page) mustRenderMainTemplate(w, r, "contacts/index.gohtml", page)
} }
@ -39,7 +37,7 @@ func GetContactForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa
mustRenderNewContactForm(w, r, form) mustRenderNewContactForm(w, r, form)
return return
} }
if !form.MustFillFromDatabase(r.Context(), conn, slug) { 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)) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@ -86,7 +84,7 @@ func HandleAddContact(w http.ResponseWriter, r *http.Request, _ httprouter.Param
return return
} }
company := mustGetCompany(r) company := mustGetCompany(r)
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) 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)
if IsHTMxRequest(r) { if IsHTMxRequest(r) {
w.Header().Set("HX-Trigger", "closeModal") w.Header().Set("HX-Trigger", "closeModal")
w.Header().Set("HX-Refresh", "true") w.Header().Set("HX-Refresh", "true")
@ -112,7 +110,7 @@ func HandleUpdateContact(w http.ResponseWriter, r *http.Request, params httprout
mustRenderEditContactForm(w, r, params[0].Value, form) mustRenderEditContactForm(w, r, params[0].Value, form)
return return
} }
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) 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)
if slug == "" { if slug == "" {
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -125,23 +123,8 @@ func HandleUpdateContact(w http.ResponseWriter, r *http.Request, params httprout
} }
} }
func mustCollectContactEntries(ctx context.Context, conn *Conn, company *Company, tag string) []*ContactEntry { func mustGetContactEntries(ctx context.Context, conn *Conn, company *Company) []*ContactEntry {
rows, err := conn.Query(ctx, ` rows, err := conn.Query(ctx, "select slug, business_name, email, phone from contact where company_id = $1 order by business_name", company.Id)
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 { if err != nil {
panic(err) panic(err)
} }
@ -150,7 +133,7 @@ func mustCollectContactEntries(ctx context.Context, conn *Conn, company *Company
var entries []*ContactEntry var entries []*ContactEntry
for rows.Next() { for rows.Next() {
entry := &ContactEntry{} entry := &ContactEntry{}
err = rows.Scan(&entry.Slug, &entry.Name, &entry.Email, &entry.Phone, &entry.Tags) err = rows.Scan(&entry.Slug, &entry.Name, &entry.Email, &entry.Phone)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -176,7 +159,6 @@ type contactForm struct {
Province *InputField Province *InputField
PostalCode *InputField PostalCode *InputField
Country *SelectField Country *SelectField
Tags *TagsField
} }
func newContactForm(ctx context.Context, conn *Conn, locale *Locale) *contactForm { func newContactForm(ctx context.Context, conn *Conn, locale *Locale) *contactForm {
@ -268,10 +250,6 @@ func newContactForm(ctx context.Context, conn *Conn, locale *Locale) *contactFor
`autocomplete="country"`, `autocomplete="country"`,
}, },
}, },
Tags: &TagsField{
Name: "tags",
Label: pgettext("input", "Tags", locale),
},
} }
} }
@ -290,7 +268,6 @@ func (form *contactForm) Parse(r *http.Request) error {
form.Province.FillValue(r) form.Province.FillValue(r)
form.PostalCode.FillValue(r) form.PostalCode.FillValue(r)
form.Country.FillValue(r) form.Country.FillValue(r)
form.Tags.FillValue(r)
return nil return nil
} }
@ -323,47 +300,3 @@ func (form *contactForm) Validate(ctx context.Context, conn *Conn) bool {
} }
return validator.AllOK() 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,8 +16,6 @@ import (
"time" "time"
) )
var tagsRegex = regexp.MustCompile("[^a-z0-9-]+")
type Attribute struct { type Attribute struct {
Key, Val string Key, Val string
} }
@ -211,49 +209,6 @@ 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) 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 { type FormValidator struct {
Valid bool Valid bool
} }

View File

@ -14,6 +14,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -364,7 +365,7 @@ func HandleAddInvoice(w http.ResponseWriter, r *http.Request, _ httprouter.Param
mustRenderNewInvoiceForm(w, r, form) mustRenderNewInvoiceForm(w, r, form)
return 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.Tags, 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.SplitTags(), NewInvoiceProductArray(form.Products))
http.Redirect(w, r, companyURI(company, "/invoices/"+slug), http.StatusSeeOther) http.Redirect(w, r, companyURI(company, "/invoices/"+slug), http.StatusSeeOther)
} }
@ -437,7 +438,7 @@ type invoiceForm struct {
Date *InputField Date *InputField
Notes *InputField Notes *InputField
PaymentMethod *SelectField PaymentMethod *SelectField
Tags *TagsField Tags *InputField
Products []*invoiceProductForm Products []*invoiceProductForm
} }
@ -474,9 +475,10 @@ func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Label: pgettext("input", "Notes", locale), Label: pgettext("input", "Notes", locale),
Type: "textarea", Type: "textarea",
}, },
Tags: &TagsField{ Tags: &InputField{
Name: "tags", Name: "tags",
Label: pgettext("input", "Tags", locale), Label: pgettext("input", "Tags", locale),
Type: "text",
}, },
PaymentMethod: &SelectField{ PaymentMethod: &SelectField{
Name: "payment_method", Name: "payment_method",
@ -605,6 +607,15 @@ 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) 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 { type invoiceProductForm struct {
locale *Locale locale *Locale
company *Company company *Company
@ -748,7 +759,7 @@ func HandleUpdateInvoice(w http.ResponseWriter, r *http.Request, params httprout
mustRenderEditInvoiceForm(w, r, slug, form) mustRenderEditInvoiceForm(w, r, slug, form)
return 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.Tags, 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.SplitTags(), EditedInvoiceProductArray(form.Products))
if slug == "" { if slug == "" {
http.NotFound(w, r) http.NotFound(w, r)
return return

View File

@ -1,7 +0,0 @@
-- 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;

View File

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

View File

@ -1,7 +0,0 @@
-- 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;

View File

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

View File

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

View File

@ -1,7 +0,0 @@
-- 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_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_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 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 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 currency_code currency 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 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 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 to_price [schema_numerus] 2023-02-05T11:46:31Z jordi fita mas <jordi@tandem.blog> # Add function to format cents to prices
@ -61,17 +61,11 @@ 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_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 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 invoice_tag [schema_numerus tag invoice] 2023-03-10T11:37:43Z jordi fita mas <jordi@tandem.blog> # Add relation for invoice tag
tag_relation [schema_numerus tag tag_name] 2023-03-25T17:40:52Z jordi fita mas <jordi@tandem.blog> # Add function to tag “relations” 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_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_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_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 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 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 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 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] 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_tag] 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

View File

@ -1,96 +0,0 @@
-- 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;

View File

@ -1,130 +0,0 @@
-- 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;

View File

@ -1,101 +0,0 @@
-- 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;

View File

@ -1,124 +0,0 @@
-- 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;

View File

@ -1,130 +0,0 @@
-- 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;

View File

@ -1,25 +0,0 @@
-- 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;

View File

@ -1,7 +0,0 @@
-- 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;

View File

@ -1,13 +0,0 @@
-- 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;

View File

@ -1,7 +0,0 @@
-- 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;

View File

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

View File

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

View File

@ -1,7 +0,0 @@
-- 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,60 +702,6 @@ tr.htmx-swapping td {
transition: opacity 1s ease-out; 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 */ /* Remix Icon */
@font-face { @font-face {

View File

@ -426,48 +426,3 @@ htmx.on('closeModal', () => {
openDialog.close(); openDialog.close();
openDialog.remove(); 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,7 +7,6 @@
<link rel="stylesheet" type="text/css" media="screen" href="/static/numerus.css"> <link rel="stylesheet" type="text/css" media="screen" href="/static/numerus.css">
<script src="/static/htmx@1.8.6.min.js"></script> <script src="/static/htmx@1.8.6.min.js"></script>
<script type="module" src="/static/numerus.js"></script> <script type="module" src="/static/numerus.js"></script>
<script defer src="/static/alpinejs@3.12.0.min.js"></script>
</head> </head>
<body> <body>
<header> <header>
@ -54,18 +53,4 @@
{{- template "content" . }} {{- template "content" . }}
</main> </main>
</body> </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> </html>

View File

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

View File

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

View File

@ -30,7 +30,6 @@
{{ template "input-field" .Province }} {{ template "input-field" .Province }}
{{ template "input-field" .PostalCode }} {{ template "input-field" .PostalCode }}
{{ template "select-field" .Country | addSelectAttr `class="width-fixed"` }} {{ template "select-field" .Country | addSelectAttr `class="width-fixed"` }}
{{ template "tags-field" .Tags }}
<fieldset> <fieldset>
<button class="primary" type="submit">{{( pgettext "New contact" "action" )}}</button> <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"> <div class="input {{ if .Errors }}has-errors{{ end }}" is="numerus-tags">
<input type="text" name="{{ .Name }}" id="{{ .Name }}-field" <input type="text" name="{{ .Name }}" id="{{ .Name }}-field"
{{- range $attribute := .Attributes }} {{$attribute}} {{ end }} {{- range $attribute := .Attributes }} {{$attribute}} {{ end }}
{{ if .Required }}required="required"{{ end }} value="{{ .String }}" placeholder="{{ .Label }}"> {{ if .Required }}required="required"{{ end }} value="{{ .Val }}" placeholder="{{ .Label }}">
<label for="{{ .Name }}-field">{{ .Label }}</label> <label for="{{ .Name }}-field">{{ .Label }}</label>
{{- if .Errors }} {{- if .Errors }}
<ul> <ul>