Add the contact relation and a rough first form

This commit is contained in:
jordi fita mas 2023-01-29 15:14:31 +01:00
parent 9968b4296a
commit 5a199a3d8e
11 changed files with 494 additions and 2 deletions

54
deploy/contact.sql Normal file
View File

@ -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;

101
pkg/contacts.go Normal file
View File

@ -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)
})
}

View File

@ -10,6 +10,8 @@ func NewRouter(db *Db) http.Handler {
companyRouter.Handle("/tax/", CompanyTaxHandler()) companyRouter.Handle("/tax/", CompanyTaxHandler())
companyRouter.Handle("/tax", CompanyTaxHandler()) companyRouter.Handle("/tax", CompanyTaxHandler())
companyRouter.Handle("/profile", ProfileHandler()) companyRouter.Handle("/profile", ProfileHandler())
companyRouter.Handle("/contacts/new", NewContactHandler())
companyRouter.Handle("/contacts", ContactsHandler())
companyRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { companyRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
mustRenderAppTemplate(w, r, "dashboard.html", nil) mustRenderAppTemplate(w, r, "dashboard.html", nil)
}) })

8
revert/contact.sql Normal file
View File

@ -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;

View File

@ -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 <jordi@tandem.blog> # Add the relation of companies and their users company_user [schema_numerus user company] 2023-01-24T17:50:06Z jordi fita mas <jordi@tandem.blog> # Add the relation of companies and their users
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] 2023-01-28T11:45:47Z jordi fita mas <jordi@tandem.blog> # Add relation for taxes tax [schema_numerus company tax_rate] 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

175
test/contact.sql Normal file
View File

@ -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;

26
verify/contact.sql Normal file
View File

@ -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;

View File

@ -244,9 +244,34 @@ header {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
background-color: var(--numerus--header--background-color); background-color: var(--numerus--header--background-color);
}
header, nav {
padding: 0rem 3rem; 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 { main {
padding: 3rem; padding: 3rem;
} }
@ -374,7 +399,6 @@ fieldset {
align-items: center; align-items: center;
border-radius: 50%; border-radius: 50%;
border: none; border: none;
list-style: none;
} }
#profilemenu summary::-webkit-details-marker { #profilemenu summary::-webkit-details-marker {
@ -434,7 +458,12 @@ fieldset {
color: var(--numerus--color--dark-gray); 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); background-color: var(--numerus--color--light-gray);
} }

View File

@ -37,6 +37,11 @@
</ul> </ul>
</details> </details>
</header> </header>
<nav aria-label="{{( pgettext "Main" "title" )}}">
<ul>
<li><a href="{{ companyURI "/contacts" }}">{{( pgettext "Customers" "nav" )}}</a></li>
</ul>
</nav>
<main> <main>
{{- template "content" . }} {{- template "content" . }}
</main> </main>

View File

@ -0,0 +1,30 @@
{{ define "content" }}
<a class="primary button" href="{{ companyURI "/contacts/new" }}">{{( pgettext "New contact" "action" )}}</a>
<table>
<thead>
<tr>
<th>{{( pgettext "All" "contact" )}}</th>
<th>{{( pgettext "Customer" "title" )}}</th>
<th>{{( pgettext "Email" "title" )}}</th>
<th>{{( pgettext "Phone" "title" )}}</th>
</tr>
</thead>
<tbody>
{{ with .Contacts }}
{{- range $tax := . }}
<tr>
<td></td>
<td>{{ .Name }}</td>
<td>{{ .Email }}</td>
<td>{{ .Phone }}</td>
</tr>
{{- end }}
{{ else }}
<tr>
<td colspan="4">{{( gettext "No customers added yet." )}}</td>
</tr>
{{ end }}
</tbody>
</table>
{{- end }}

View File

@ -0,0 +1,61 @@
{{ define "content" }}
<section class="dialog-content">
<h2>{{(pgettext "New Contact" "title")}}</h2>
<form method="POST" action="{{ companyURI "/contacts" }}">
<div class="input">
<input type="text" name="business_name" id="business_name" required="required" autofocus value="{{ .BusinessName }}" placeholder="{{( pgettext "Business name" "input" )}}">
<label for="business_name">{{( pgettext "Business name" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="vatin" id="vatin" required="required" value="{{ .VATIN }}" placeholder="{{( pgettext "VAT number" "input" )}}">
<label for="vatin">{{( pgettext "VAT number" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="trade_name" id="trade_name" value="{{ .TradeName }}" placeholder="{{( pgettext "Trade name" "input" )}}">
<label for="trade_name">{{( pgettext "Trade name" "input" )}}</label>
</div>
<div class="input">
<input type="tel" name="phone" id="phone" required="required" value="{{ .Phone }}" placeholder="{{( pgettext "Phone" "input" )}}">
<label for="phone">{{( pgettext "Phone" "input" )}}</label>
</div>
<div class="input">
<input type="email" name="email" id="email" required="required" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}">
<label for="email">{{( pgettext "Email" "input" )}}</label>
</div>
<div class="input">
<input type="url" name="web" id="web" value="{{ .Web }}" placeholder="{{( pgettext "Web" "input" )}}">
<label for="web">{{( pgettext "Web" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="address" id="address" class="width-2x" required="required" value="{{ .Address }}" placeholder="{{( pgettext "Address" "input" )}}">
<label for="address">{{( pgettext "Address" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="city" id="city" required="required" value="{{ .City }}" placeholder="{{( pgettext "City" "input" )}}">
<label for="city">{{( pgettext "City" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="province" id="province" required="required" value="{{ .City }}" placeholder="{{( pgettext "Province" "input" )}}">
<label for="province">{{( pgettext "Province" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="postal_code" id="postal_code" required="required" value="{{ .PostalCode }}" placeholder="{{( pgettext "Postal code" "input" )}}">
<label for="postal_code">{{( pgettext "Postal code" "input" )}}</label>
</div>
<div class="input">
<select id="country" name="country" class="width-fixed">
{{- range $country := .Countries }}
<option value="{{ .Code }}" {{ if eq .Code $.CountryCode }}selected="selected"{{ end }}>{{ .Name }}</option>
{{- end }}
</select>
<label for="country">{{( pgettext "Country" "input" )}}</label>
</div>
<fieldset>
<button type="submit">{{( pgettext "New contact" "action" )}}</button>
</fieldset>
</form>
</section>
{{- end }}