diff --git a/deploy/contact.sql b/deploy/contact.sql new file mode 100644 index 0000000..6c44ca0 --- /dev/null +++ b/deploy/contact.sql @@ -0,0 +1,54 @@ +-- Deploy numerus:contact to pg +-- requires: schema_numerus +-- requires: company +-- requires: extension_vat +-- requires: email +-- requires: extension_pg_libphonenumber +-- requires: extension_uri +-- requires: currency_code +-- requires: currency +-- requires: country_code +-- requires: country + +begin; + +set search_path to numerus, public; + +create table contact ( + contact_id serial primary key, + company_id integer not null references company, + slug uuid not null unique default gen_random_uuid(), + business_name text not null, + vatin vatin not null, + trade_name text not null, + phone packed_phone_number not null, + email email not null, + web uri not null, + address text not null, + city text not null, + province text not null, + postal_code text not null, + country_code country_code not null references country, + created_at timestamptz not null default current_timestamp +); + +grant select, insert, update, delete on table contact to invoicer; +grant select, insert, update, delete on table contact to admin; + +grant usage on sequence contact_contact_id_seq to invoicer; +grant usage on sequence contact_contact_id_seq to admin; + +alter table contact enable row level security; + +create policy company_policy +on contact +using ( + exists( + select 1 + from company_user + join user_profile using (user_id) + where company_user.company_id = contact.company_id + ) +); + +commit; diff --git a/pkg/contacts.go b/pkg/contacts.go new file mode 100644 index 0000000..d40bac8 --- /dev/null +++ b/pkg/contacts.go @@ -0,0 +1,101 @@ +package pkg + +import ( + "context" + "net/http" +) + +type ContactEntry struct { + Name string + Email string + Phone string +} + +type ContactsIndexPage struct { + Title string + Contacts []*ContactEntry +} + +func ContactsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn := getConn(r) + company := getCompany(r) + if r.Method == "POST" { + r.ParseForm() + page := &NewContactPage{ + BusinessName: r.FormValue("business_name"), + VATIN: r.FormValue("vatin"), + TradeName: r.FormValue("trade_name"), + Phone: r.FormValue("phone"), + Email: r.FormValue("email"), + Web: r.FormValue("web"), + Address: r.FormValue("address"), + City: r.FormValue("city"), + Province: r.FormValue("province"), + PostalCode: r.FormValue("postal_code"), + CountryCode: r.FormValue("country"), + } + conn.MustExec(r.Context(), "insert into contact (company_id, business_name, vatin, trade_name, phone, email, web, address, province, city, 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, page.BusinessName, page.VATIN, page.TradeName, page.Phone, page.Email, page.Web, page.Address, page.City, page.Province, page.PostalCode, page.CountryCode) + http.Redirect(w, r, "/company/"+company.Slug+"/contacts", http.StatusSeeOther) + } else { + locale := getLocale(r) + page := &ContactsIndexPage{ + Title: pgettext("title", "Contacts", locale), + Contacts: mustGetContactEntries(r.Context(), conn, company), + } + mustRenderAppTemplate(w, r, "contacts-index.html", page) + } + }) +} + +func mustGetContactEntries(ctx context.Context, conn *Conn, company *Company) []*ContactEntry { + rows, err := conn.Query(ctx, "select business_name, email, phone from contact where company_id = $1 order by business_name", company.Id) + if err != nil { + panic(err) + } + defer rows.Close() + + var entries []*ContactEntry + for rows.Next() { + entry := &ContactEntry{} + err = rows.Scan(&entry.Name, &entry.Email, &entry.Phone) + if err != nil { + panic(err) + } + entries = append(entries, entry) + } + if rows.Err() != nil { + panic(rows.Err()) + } + + return entries +} + +type NewContactPage struct { + Title string + BusinessName string + VATIN string + TradeName string + Phone string + Email string + Web string + Address string + City string + Province string + PostalCode string + CountryCode string + Countries []CountryOption +} + +func NewContactHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + locale := getLocale(r) + conn := getConn(r) + page := &NewContactPage{ + Title: pgettext("title", "New Contact", locale), + CountryCode: "ES", + Countries: mustGetCountryOptions(r.Context(), conn, locale), + } + mustRenderAppTemplate(w, r, "contacts-new.html", page) + }) +} diff --git a/pkg/router.go b/pkg/router.go index 77c0789..6511c2f 100644 --- a/pkg/router.go +++ b/pkg/router.go @@ -10,6 +10,8 @@ func NewRouter(db *Db) http.Handler { companyRouter.Handle("/tax/", CompanyTaxHandler()) companyRouter.Handle("/tax", CompanyTaxHandler()) companyRouter.Handle("/profile", ProfileHandler()) + companyRouter.Handle("/contacts/new", NewContactHandler()) + companyRouter.Handle("/contacts", ContactsHandler()) companyRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { mustRenderAppTemplate(w, r, "dashboard.html", nil) }) diff --git a/revert/contact.sql b/revert/contact.sql new file mode 100644 index 0000000..a0eefe2 --- /dev/null +++ b/revert/contact.sql @@ -0,0 +1,8 @@ +-- Revert numerus:contact from pg + +begin; + +drop policy if exists company_policy on numerus.contact; +drop table if exists numerus.contact; + +commit; diff --git a/sqitch.plan b/sqitch.plan index cea9d37..49e7c25 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -38,3 +38,4 @@ company [schema_numerus extension_vat email extension_pg_libphonenumber extensio company_user [schema_numerus user company] 2023-01-24T17:50:06Z jordi fita mas # Add the relation of companies and their users tax_rate [schema_numerus] 2023-01-28T11:33:39Z jordi fita mas # Add domain for tax rates tax [schema_numerus company tax_rate] 2023-01-28T11:45:47Z jordi fita mas # 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 # Add the relation for contacts diff --git a/test/contact.sql b/test/contact.sql new file mode 100644 index 0000000..6243c5b --- /dev/null +++ b/test/contact.sql @@ -0,0 +1,175 @@ +-- Test contact +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(84); + +set search_path to numerus, auth, public; + +select has_table('contact'); +select has_pk('contact' ); +select table_privs_are('contact', 'guest', array []::text[]); +select table_privs_are('contact', 'invoicer', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('contact', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('contact', 'authenticator', array []::text[]); + +select has_sequence('contact_contact_id_seq'); +select sequence_privs_are('contact_contact_id_seq', 'guest', array[]::text[]); +select sequence_privs_are('contact_contact_id_seq', 'invoicer', array['USAGE']); +select sequence_privs_are('contact_contact_id_seq', 'admin', array['USAGE']); +select sequence_privs_are('contact_contact_id_seq', 'authenticator', array[]::text[]); + +select has_column('contact', 'contact_id'); +select col_is_pk('contact', 'contact_id'); +select col_type_is('contact', 'contact_id', 'integer'); +select col_not_null('contact', 'contact_id'); +select col_has_default('contact', 'contact_id'); +select col_default_is('contact', 'contact_id', 'nextval(''contact_contact_id_seq''::regclass)'); + +select has_column('contact', 'company_id'); +select col_is_fk('contact', 'company_id'); +select fk_ok('contact', 'company_id', 'company', 'company_id'); +select col_type_is('contact', 'company_id', 'integer'); +select col_not_null('contact', 'company_id'); +select col_hasnt_default('contact', 'company_id'); + +select has_column('contact', 'slug'); +select col_is_unique('contact', 'slug'); +select col_type_is('contact', 'slug', 'uuid'); +select col_not_null('contact', 'slug'); +select col_has_default('contact', 'slug'); +select col_default_is('contact', 'slug', 'gen_random_uuid()'); + +select has_column('contact', 'business_name'); +select col_type_is('contact', 'business_name', 'text'); +select col_not_null('contact', 'business_name'); +select col_hasnt_default('contact', 'business_name'); + +select has_column('contact', 'vatin'); +select col_type_is('contact', 'vatin', 'vatin'); +select col_not_null('contact', 'vatin'); +select col_hasnt_default('contact', 'vatin'); + +select has_column('contact', 'trade_name'); +select col_type_is('contact', 'trade_name', 'text'); +select col_not_null('contact', 'trade_name'); +select col_hasnt_default('contact', 'trade_name'); + +select has_column('contact', 'phone'); +select col_type_is('contact', 'phone', 'packed_phone_number'); +select col_not_null('contact', 'phone'); +select col_hasnt_default('contact', 'phone'); + +select has_column('contact', 'email'); +select col_type_is('contact', 'email', 'email'); +select col_not_null('contact', 'email'); +select col_hasnt_default('contact', 'email'); + +select has_column('contact', 'web'); +select col_type_is('contact', 'web', 'uri'); +select col_not_null('contact', 'web'); +select col_hasnt_default('contact', 'web'); + +select has_column('contact', 'address'); +select col_type_is('contact', 'address', 'text'); +select col_not_null('contact', 'address'); +select col_hasnt_default('contact', 'address'); + +select has_column('contact', 'city'); +select col_type_is('contact', 'city', 'text'); +select col_not_null('contact', 'city'); +select col_hasnt_default('contact', 'city'); + +select has_column('contact', 'province'); +select col_type_is('contact', 'province', 'text'); +select col_not_null('contact', 'province'); +select col_hasnt_default('contact', 'province'); + +select has_column('contact', 'postal_code'); +select col_type_is('contact', 'postal_code', 'text'); +select col_not_null('contact', 'postal_code'); +select col_hasnt_default('contact', 'postal_code'); + +select has_column('contact', 'country_code'); +select col_type_is('contact', 'country_code', 'country_code'); +select col_is_fk('contact', 'country_code'); +select col_type_is('contact', 'country_code', 'country_code'); +select col_not_null('contact', 'country_code'); +select col_hasnt_default('contact', 'country_code'); + +select has_column('contact', 'created_at'); +select col_type_is('contact', 'created_at', 'timestamp with time zone'); +select col_not_null('contact', 'created_at'); +select col_has_default('contact', 'created_at'); +select col_default_is('contact', 'created_at', current_timestamp); + +set client_min_messages to warning; +truncate contact cascade; +truncate company_user cascade; +truncate company 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') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD') +; + +insert into company_user (company_id, user_id) +values (2, 1) + , (4, 5) +; + +insert into contact (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code) +values (2, 'Contact 1', 'XX555', '', '777-777-777', 'c@c', '', '', '', '', '', 'ES') + , (4, 'Contact 2', 'XX666', '', '888-888-888', 'd@d', '', '', '', '', '', 'ES') +; + +prepare contact_data as +select company_id, business_name +from contact +order by company_id, business_name; + +set role invoicer; +select is_empty('contact_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog'); +select bag_eq( + 'contact_data', + $$ values (2, 'Contact 1') + $$, + 'Should only list contacts of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog'); +select bag_eq( + 'contact_data', + $$ values (4, 'Contact 2') + $$, + 'Should only list contacts of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie'); +select throws_ok( + 'contact_data', + '42501', 'permission denied for table contact', + 'Should not allow select to guest users' +); +reset role; + +select * +from finish(); + +rollback; + diff --git a/verify/contact.sql b/verify/contact.sql new file mode 100644 index 0000000..75d9ea1 --- /dev/null +++ b/verify/contact.sql @@ -0,0 +1,26 @@ +-- Verify numerus:contact on pg + +begin; + +select contact_id + , company_id + , slug + , business_name + , vatin + , trade_name + , phone + , email + , web + , address + , city + , province + , postal_code + , country_code + , created_at +from numerus.contact +where false; + +select 1 / count(*) from pg_class where oid = 'numerus.contact'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'numerus.contact'::regclass; + +rollback; diff --git a/web/static/numerus.css b/web/static/numerus.css index 16463a6..542c593 100644 --- a/web/static/numerus.css +++ b/web/static/numerus.css @@ -244,9 +244,34 @@ header { justify-content: space-between; align-items: center; background-color: var(--numerus--header--background-color); +} + +header, nav { padding: 0rem 3rem; } +nav { + border-bottom: 1px solid var(--numerus--color--light-gray); +} + +nav ul { + display: flex; + list-style: none; + padding: 0; +} + +nav li { + flex: 1; +} + +nav a { + text-decoration: none; + color: inherit; + min-height: 8rem; + display: flex; + align-items: center; +} + main { padding: 3rem; } @@ -374,7 +399,6 @@ fieldset { align-items: center; border-radius: 50%; border: none; - list-style: none; } #profilemenu summary::-webkit-details-marker { @@ -434,7 +458,12 @@ fieldset { color: var(--numerus--color--dark-gray); } -#profilemenu summary:hover, #profilemenu summary:focus, #profilemenu button:hover, #profilemenu a:hover { + #profilemenu summary:hover +, #profilemenu summary:focus +, #profilemenu button:hover +, #profilemenu a:hover +, nav a:hover +{ background-color: var(--numerus--color--light-gray); } diff --git a/web/template/app.html b/web/template/app.html index d6d270b..3032990 100644 --- a/web/template/app.html +++ b/web/template/app.html @@ -37,6 +37,11 @@ +
{{- template "content" . }}
diff --git a/web/template/contacts-index.html b/web/template/contacts-index.html new file mode 100644 index 0000000..3251a00 --- /dev/null +++ b/web/template/contacts-index.html @@ -0,0 +1,30 @@ +{{ define "content" }} +{{( pgettext "New contact" "action" )}} + + + + + + + + + + + + {{ with .Contacts }} + {{- range $tax := . }} + + + + + + + {{- end }} + {{ else }} + + + + {{ end }} + +
{{( pgettext "All" "contact" )}}{{( pgettext "Customer" "title" )}}{{( pgettext "Email" "title" )}}{{( pgettext "Phone" "title" )}}
{{ .Name }}{{ .Email }}{{ .Phone }}
{{( gettext "No customers added yet." )}}
+{{- end }} diff --git a/web/template/contacts-new.html b/web/template/contacts-new.html new file mode 100644 index 0000000..5808a6a --- /dev/null +++ b/web/template/contacts-new.html @@ -0,0 +1,61 @@ +{{ define "content" }} +
+

{{(pgettext "New Contact" "title")}}

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+{{- end }}