Compare commits

..

4 Commits

Author SHA1 Message Date
jordi fita mas 5a199a3d8e Add the contact relation and a rough first form 2023-01-29 15:14:31 +01:00
jordi fita mas 9968b4296a Add a “if not exists” to tax revert script 2023-01-29 15:13:47 +01:00
jordi fita mas 717ae9d5d4 Add an (optional) suffix to labels of optional fields
For now i use CSS because we are not sure whether we will keep it this
way or not and, until we finally decide, with CSS is the easiest to
remove later on.
2023-01-29 15:13:47 +01:00
jordi fita mas 1712a81dfc Move the /profile under the company router
This is not necessary per se, but it makes my life easier because that
way i know which company the user was when she went to its profile and
can “return” back in the menu and future nav items.
2023-01-29 15:13:47 +01:00
12 changed files with 506 additions and 5 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

@ -9,6 +9,9 @@ func NewRouter(db *Db) http.Handler {
companyRouter.Handle("/tax-details", CompanyTaxDetailsHandler()) companyRouter.Handle("/tax-details", CompanyTaxDetailsHandler())
companyRouter.Handle("/tax/", CompanyTaxHandler()) companyRouter.Handle("/tax/", CompanyTaxHandler())
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) { companyRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
mustRenderAppTemplate(w, r, "dashboard.html", nil) mustRenderAppTemplate(w, r, "dashboard.html", nil)
}) })
@ -17,7 +20,6 @@ func NewRouter(db *Db) http.Handler {
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
router.Handle("/login", LoginHandler()) router.Handle("/login", LoginHandler())
router.Handle("/logout", Authenticated(LogoutHandler())) router.Handle("/logout", Authenticated(LogoutHandler()))
router.Handle("/profile", Authenticated(ProfileHandler()))
router.Handle("/company/", Authenticated(http.StripPrefix("/company/", CompanyHandler(companyRouter)))) router.Handle("/company/", Authenticated(http.StripPrefix("/company/", CompanyHandler(companyRouter))))
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
user := getUser(r) user := getUser(r)

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

@ -2,7 +2,7 @@
begin; begin;
drop policy company_policy on numerus.tax; drop policy if exists company_policy on numerus.tax;
drop table if exists numerus.tax; drop table if exists numerus.tax;
commit; 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;
} }
@ -295,6 +320,15 @@ input.width-2x {
top: 1rem; top: 1rem;
} }
[lang="en"] input:not([required]) + label::after {
content: " (optional)"
}
[lang="ca"] input:not([required]) + label::after
, [lang="es"] input:not([required]) + label::after {
content: " (optional)"
}
.input label, .input input:focus ~ label { .input label, .input input:focus ~ label {
background-color: var(--numerus--background-color); background-color: var(--numerus--background-color);
top: -.9rem; top: -.9rem;
@ -365,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 {
@ -425,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

@ -15,7 +15,7 @@
</summary> </summary>
<ul role="menu"> <ul role="menu">
<li role="presentation"> <li role="presentation">
<a role="menuitem" href="/profile"> <a role="menuitem" href="{{ companyURI "/profile" }}">
<i class="ri-account-circle-line"></i> <i class="ri-account-circle-line"></i>
{{( pgettext "Account" "menu" )}} {{( pgettext "Account" "menu" )}}
</a> </a>
@ -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 }}