Commit Graph

26 Commits

Author SHA1 Message Date
jordi fita mas f2143cd0e6 Add the admin page to see payments
Had to do a couple of changes to the database: add the currency_code to
the payment relation, to format the price according to the payment’s
currency instead of the company’s; and the reference SQL function, to
replace the equivalent golang function, so that i can use it to index
payments.

The rest is mostly the same as any other page, except that the
individual payment’s page is not a form, but a regular info dump.

I also moved the payment settings as a sub-route of payments, as i
believe this makes more sense than an additional user menu item.
2024-02-14 04:54:42 +01:00
jordi fita mas 4a7b0112ef Send an email on notification of success payment
To send the actual mail with sendmail, i have stolen the code from
go-mail[0] and removed everything i did not need.  This is because there
is no Go package to send email in Debian 12, and this was easier than
to build the DEB for go-mail.

Once i have the time….

[0]: https://go-mail.dev/
2024-02-13 05:20:35 +01:00
jordi fita mas e5023a2a41 Handle the booking cart entirely with HTMx
Besides the dynamic final cart, that was already handled by HTMx, i had
to check the maximum number of guests, whether the accommodation allows
“overflow”, whether dogs are allowed, and that the booking dates were
within the campground’s opening and closing dates.

I could do all of this with AlpineJS, but then i would have to add the
same validation to the backend, prior to accept the payment.  Would not
make more sense to have them in a single place, namely the backend? With
HTMx i can do that.

However, i now have to create the form “piecemeal”, because i may not
have the whole information when the visitor arrives to the booking page,
and i still had the same problem as in commit d2858302efa—parsing the
whole form as is would leave guests and options field empty, rather than
at their minimum values.

One of the fieldsets in that booking form are the arrival and departure
dates, that are the sames we use in the campsite type’s page to
“preselect” these values.  Since now are in a separate struct, i can
reuse the same type and validation logic for both pages, making my
JavaScript code useless, but requiring HTMx.  I think this is a good
tradeoff, in fact.
2024-02-10 03:49:44 +01:00
jordi fita mas b6044a7d4a Advance min dates of departure and arrival one day
Apparently, expecting people to book at least one day in advance is
being “too optimistic”.
2024-02-02 02:59:41 +01:00
jordi fita mas 4f04d973c2 Dynamically set min and max to arrival and departure date inputs
The departure must be at list one day after the arrival, but no longer
than seven, due to campground’s policy.
2024-01-31 20:00:38 +01:00
jordi fita mas 51540151ff Refactor ISO date, and datestamp format in constant 2024-01-31 19:58:46 +01:00
jordi fita mas cf527ce070 Add the application’s version to the footer
This is mostly because it is required for the “Digital Kit”, but it also
works in our favor because now i can version the URL to the static
resources.

Go 1.18 adds the info from git if the package is build from a git
repository, but this is not the case in OBS, so i instead relay on a
constant for the version number.  This constant is “updated” by Debian’s
rules, mostly due to the discussion in [0].

[0]: https://github.com/golang/go/issues/22706
2024-01-21 20:50:16 +01:00
jordi fita mas d945f55096 Add “part” of the bookings’ management
“Part”, because it is not possible to add or actually manage any
booking yet, but it has the export feature that we need to validate the
project.
2024-01-18 21:05:30 +01:00
jordi fita mas 0beb0bb315 Add VAT and tourist tax information
Since i need the number of decimals to format the price in the template,
i added them to the company, and used them in the call to price_parse().
2024-01-15 01:45:58 +01:00
jordi fita mas 96fb253354 Show the campsite type’s calendar in an “infinite scroll” carousel
Oriol does not want to waste so much vertical space for the calendar,
and wants it to show in a carousel, initially with only 6 months, and
loading the next three each time the user scrolls past the last.

I now use HTMx in the public page too for this auto-loading behavior,
based on their “infinite scroll” example[0].

Had to put the /calendar URI inside campsites because in the
calendar.gohtml i do not know the current type’s UUID, and can not use
a relative URL to “add subdirectories”, because the type does not end
with a slash.

Had to change season.CollectCalendar to expect the first month and a
number of months to show, to be able to load only 6 or 3 months after
the current, for the initial carousel content, or after the last month
of the carousel.

[0]: https://htmx.org/examples/infinite-scroll/
2023-10-18 21:06:41 +02:00
jordi fita mas 852acaccc3 Add the calendar to the public campsite type page
I had to export the Calendar type from Season to use it from
campsite/types, and also renamed them because season.SeasonCalendar is
a bit redundant compared to just season.Calendar.

I still have not added the HTMx code to switch year because i am not
sure whether Oriol will want to show a whole year or just half a year.

The calculation for the text color taking into account the contrast with
the background is from [0].

[0]: https://www.smashingmagazine.com/2020/07/css-techniques-legibility/#foreground-contrast
2023-10-14 23:14:23 +02:00
jordi fita mas caab52a274 Add the campground page
For now, this page only shows the map, that i had to create a new
“files” template function to include it as an extra template file.
2023-10-06 22:14:11 +02:00
jordi fita mas ae29726fa2 Add the footer site map
I created a common template to show the company address in the footer
and the contact page, and then i realized Go did not like to output my
phone URL in the anchor without having the tel: schema in the template.
I then removed that variable and now the URL is created with tel: and
the phone number with its spaces removed.
2023-10-06 22:02:59 +02:00
jordi fita mas 6939670dfc Add a year navigator to the seasons’ calendar 2023-09-29 18:20:16 +02:00
jordi fita mas e3503187d3 Add campsite map in SVG
I intend to use the same SVG file for customers and employees, so i had
to change Oriol’s design to add a class to layers that are supposed to
be only for customers, like trees.  These are hidden in the admin area.

I understood that customers and employees have to click on a campsite to
select it, and then they can book or whatever they need to do to them.
Since customers and employees most certainly will need to have different
listeners on campsites, i decided to add the link with JavaScript.  To
do so, i need a custom XML attribute with the campsite’s identifier.

Since i have seen that all campsites have a label, i changed the
“identifier” to the unique combination (company_id, label).  The
company_id is there because different companies could have the same
label; i left the campsite_id primary key for foreign constraints.

In this case, as a test, i add an <a> element to the campsite with a
link to edit it; we’ll discuss with Oriol what exactly it needs to do.

However, the original design had the labels in a different layer, that
interfered with the link, as the numbers must be above the path and
the link must wrap the path in order to “inherit” its shape.  I had no
other recourse than to move the labels in the same layer as the paths’.
2023-09-24 03:17:13 +02:00
jordi fita mas b5d40cc262 Add the upload form to the media picker
It makes easier to upload new images from the place where we need it,
instead of having to go to the media section each time.

It was a little messy, this one.

First of all, I realized that POSTint to /admin/media/picker to get the
new media field was wrong: i was not asking the server to “accept an
entity”, but only requesting a new HTML value, just like a GET to
/admin/media/upload requests the form to upload a new media, thus here
i should do the same, except i needed the query parameters to change the
field, which is fine—it is actually a different resource, thus a
different URL.

Then, i thought that i could not POST the upload to /admin/media,
because i returned a different HTML —the media field—, so i reused the
recently unused POST to /admin/media/picker to upload that file and
return the HTML for the field.  It was wrong, because i was not
requesting the server to put the file as a subordinate of
/admin/media/picker, only /admin/media, but i did not come up with any
other solution.

Since i had two different upload functions now, i created uploadForm’s
Handle method to refactorize the duplicated logic to a single place.
Unfortunately, i did not work as i expected because uploadForm’s and
mediaPicker’s MustRender methods are different, and mediaPicker has to
embed uploadForm to render the form in the picker.  That made me change
Handle’s output to a boolean and error in order for the HTTP handler
function know when to render the form with the error messages with the
proper MustRender handler.

However, I saw the opportunity of reusing that Handler method for
editMedia, that was doing mostly the same job, but had to call a
different Validate than uploadForm’s, because editMedia does not require
the uploaded file.  That’s when i realized that i could use an interface
and that this interface could be reused not only within media but
throughout the application, and added HandleMultipart in form.

Had to create a different interface for multipart forms because they
need different parameters in Parse that non-multipart form, when i add
that interface, hence had to also change Parse to ParseForm to account
for the difference in signature; not a big deal.

After all that, i realized that i **could** POST to /admin/media in both
cases, because i always return “an HTML entity”, it just happens that
for the media section it is empty with a redirect, and for the picker is
the field.  That made the whole Handle method a bit redundant, but i
left it nevertheless, as i find it slightly easier to read the
uploadMedia function now.
2023-09-22 01:40:22 +02:00
jordi fita mas 97cf117da3 Manage all media uploads in a single place
It made no sense to have a file upload in each form that needs a media,
because to reuse an existing media users would need to upload the exact
same file again; this is very unusual and unfriendly.

A better option is to have a “centralized” media section, where people
can upload files there, and then have a picker to select from there.
Ideally, there would be an upload option in the picker, but i did not
add it yet.

I’ve split the content from the media because i want users to have the
option to update a media, for instance when they need to upload a
reduced or cropped version of the same photo, without an edit they would
need to upload the file as a new media and then update all places where
the old version was used.  And i did not want to trouble people that
uploads the same photo twice: without the separate relation, doing so
would throw a constraint error.

I do not believe there is any security problem to have all companies
link their media to the same file, as they were already readable by
everyone and could upload the data from a different company to their
own; in other words, it is not worse than it was now.
2023-09-21 01:56:44 +02:00
jordi fita mas 50fbfce9ee Add the form to update company’s tax details
It is inside the “user menu” only because this is where Numerus has the
same option, although it makes less sense in this case, because Numerus
is geared toward individual freelancers while Camper is for companies.
But, since it is easy to change afterward, this will do for now.

However, it should be only shown to admin users, because regular
employees have no UPDATE privilege on the company relation.  Thus, the
need for a new template function to check if the user is admin.

Part of #17.
2023-08-15 22:35:21 +02:00
jordi fita mas c010b1adba Replace Gutenberg with GrapesJS for pages
I simply can not use Gutenberg without having it choking in its own
over-engineered architecture: using it inside a form, submits it when
clicking the button to change a paragraph’s text size; and using the
custom text size in pixels causes the paragraph component to fail.

The issue with paragraph’s custom text size is that block-editor’s
typography hook expects the font size to be a string, such as '12px' or
'1em', to call startsWith on it, but the paragraph sets an integer,
always assuming that the units are pixels.  Integers do not have a
startsWith method.

Looking at the Gutenberg distributed with the current version of
WordPress, 6.3, seems that now paragraph has a selector for the units,
therefore never sets just the integer.  That made me think that the
components used by the Isolated Block Editor are “mismatched”: maybe in
a previous version of block-editor it was always passed as an integer
too?

I downloaded the source code of the Isolated Block Editor and tried to
update @wordpress/block-library from version 8.14.0 to the current
version, 8.16.0, but fails with an error saying that 'core/paragraph' is
not registered, when, as far as i could check, it was.  Seems that
something changed in @wordpress/blocks between version 12.14.0 and
12.16.0, so i tried to upgrade that module as well; it did not work
because @wordpress/data was not updated —do not remember the actual
error message—.  Upgrading to @wordpress/data from 9.7.0 to 9.9.0 made
the registration of the 'isolated/editor' subregistry to be apparently
ignored, because the posterior select('isolated/editor') within a
withSelect hook returns undefined.

At this point, i gave up: it is obvious that the people that shit
JavaScript for Gutenberg do not care for semantic versioning, and there
are a lot of moving parts to fix just to be able to use a simple
paragraph block!

It seems, however, that there are not many open-source, block-based
_layout_ editors out there: mainly GrapesJS and Craft.JS.  Craft.JS,
however, has no way to output HTML[0], requiring hacks such as using
React to generate the HTML and then pasted that shit onto the page;
totally useless for me.

I am not a fan of GrapesJS either: it seems that the “text block” is
a content-editable div, and semantic HTML can go fuck itself,
apparently.  Typical webshit mentality. By strapping another huge
dependency like CKEditor, but only up to the already out-of-support
version 4, i can write headers, paragraphs and list.  That’s
something, i guess.

[0]: https://github.com/prevwong/craft.js/issues/42

Part of #33.
2023-08-11 02:40:02 +02:00
jordi fita mas e128680e9a Split templates and handlers into admin and public
I need to check that the user is an employee (or admin) in
administration handlers, but i do not want to do it for each handler,
because i am bound to forget it.  Thus, i added the /admin sub-path for
these resources.

The public-facing web is the rest of the resources outside /admin, but
for now there is only home, to test whether it works as expected or not.

The public-facing web can not relay on the user’s language settings, as
the guest user has no way to set that.  I would be happy to just use the
Accept-Language header for that, but apparently Google does not use that
header[0], and they give four alternatives: a country-specific domain,
a subdomain with a generic top-level domain (gTLD), subdirectories with
a gTLD, or URL parameters (e.g., site.com?loc=de).

Of the four, Google does not recommend URL parameters, and the customer
is already using subdirectories with the current site, therefor that’s
what i have chosen.

Google also tells me that it is a very good idea to have links between
localized version of the same resources, either with <link> elements,
Link HTTP response headers, or a sitemap file[1]; they are all
equivalent in the eyes of Google.

I have choosen the Link response headers way, because for that i can
simply “augment” ResponseHeader to automatically add these headers when
the response status is 2xx, otherwise i would need to pass down the
original URL path until it reaches the template.

Even though Camper is supposed to be a “generic”, multi-company
application, i think i will stick to the easiest route and write the
templates for just the “first” customer.

[0]: https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites
[1]: https://developers.google.com/search/docs/specialty/international/localized-versions
2023-08-05 03:42:37 +02:00
jordi fita mas 0d2812acc5 Use HTTP Host to establish the request’s company
We made the decision that this application will also serve the public
pages to guests and customers, to avoid the overhead of having to
synchronize all data between this application and a bespoke WordPress
plugin.

That means that i no longer can have a /company/slug in the URL to know
which company the request is for, not only because it looks ugly but
because guest users do not have a “main company”—or any company
whatsoever.

Since the public-facing web is going to be served through a valid DNS
domain, and all companies are going to have a different domain, i
realized this is enough: i only had to add a relation of company and
their hosts.  The same company can have many hosts for staging servers
or to separate the administration and public parts, for instance.

With change, the company is already known from the first handler, and
can pass it down to all the others, not only the handlers under
/company/slug/whatever.  And i no longer need the companyURL function,
as there is no more explicit company in the URL.

Even though template technically does not need the template, as it only
contains the ID —the rest of the data is in a relation inaccessible to
guests for now—, but i left the parameter just in case later on i need
the decimal digits or currency symbol for whatever reason.
2023-08-03 20:21:21 +02:00
jordi fita mas f65110824e Add the company’s slug in the URL before company-dependent handlers
I really doubt that they are going to use more than a single company,
but the application is based on Numerus, that **does** have multiple
company, and followed the same architecture and philosophy: use the URL
to choose the company to manage, even if the user has a single company.

The reason i use the slug instead of the ID is because i do not want to
make the ID public in case the application is really used by employees
of many unrelated companies: they need not need to guess how many
companies there are based on the ID.

I validate this slug to be a valid UUID instead of relaying on the
query’s empty result because casting a string with a malformed value to
UUID results in an error other than data not found.  Not with that
select, but it would fail with a function parameter, and i want to add
that UUID check to all functions that do use slugs.

I based uuid.Valid function on Parse() from Google’s uuid package[0]
instead of using regular expression, as it was my first idea, because
that function is an order of magnitude faster in benchmarks:

  goos: linux
  goarch: amd64
  pkg: dev.tandem.ws/tandem/numerus/pkg
  cpu: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
  BenchmarkValidUuid-4            36946050                29.37 ns/op
  BenchmarkValidUuid_Re-4          3633169               306.70 ns/op

The regular expression used for the benchmark was:

  var re = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")

And the input parameter for both functions was the following valid UUID,
because most of the time the passed UUID will be valid:

  "f47ac10b-58cc-0372-8567-0e02b2c3d479"

I did not use the uuid package as is, even though it is in Debian’s
repository, because i only need to check whether the value is valid,
not convert it to a byte array.  As far as i know, that package can not
do that.

Adding the Company struct into auth was not my intention, as it makes
little sense name-wise, but i need to have the Company when rendering
templates and the company package has templates to render, thus using
the company package for the Company struct would create a dependency
loop between template and company.  I’ve chosen the auth package only
because User is also there; User and Company are very much related in
this application, but not enough to include the company inside the user,
or vice versa, as the User comes from the cookie while the company from
the URL.

Finally, had to move methodNotAllowed to the http package, as an
exported function, because it is used now from other packages, namely
campsite.

[0]: https://github.com/google/uuid
2023-07-31 18:51:50 +02:00
jordi fita mas f963f54839 Add profile form, inside a user menu
This is the first form that uses HTMx, and can not return a 400 error
code because otherwise HTMx does not render the content.

Since now there are pages that do not render the whole html, with header
and body, i need a different layout for these, and moved the common code
to handle forms and such a new template file that both layouts can use.
I also need the request in template.MustRender to check which layout i
should use.

Closes #7.
2023-07-26 20:46:09 +02:00
jordi fita mas ebe8217862 Add the logout button
Conceptually, to logout we have to “delete the session”, thus the best
HTTP verb would be `DELETE`.  However, there is no way to send a
`DELETE` request with a regular HTML form, and it seems that never will
be[0].

I could use a POST, optionally with a “method override” technique, but
i was planing to use HTMx anyway, so this was as good an opportunity to
include it as any.

In this application i am not concerned with people not having JavaScript
enabled, because it is for a customer that has a known environment, and
we do not have much time anyway.  Therefore, i opted to forgo
progressive enhancement in cases like this: if `DELETE` is needed, use
`hx-delete`.

Unfortunately, i can not use a <form> with a hidden <input> for the
CSRF token, because `DELETE` requests do not have body and the value
should be added as query parameters, like a form with GET method, but
HTMx does the incorrect thing here: sends the values in the request’s
body.  That’s why i have to use a custom header and the `hx-header`
directive to include the CSRF token.

Then, by default HTMx targets the triggered element for swap with the
response from the server, but after a logout i want to redirect the
user to the login form again.  I could set the hx-target to button to
replace the whole body, or tell the client to redirect to the new
location.  I actually do not know which one is “better”.  Maybe the
hx-target is best because then everything is handled by the client, but
in the case of logout, since it is possible that i might want to load
scripts only for logged-in users in the future, i opted for the full
page reload.

However, HTMx does not want to reload a page that return HTTP 401,
hence i had to include the GET method to /login in order to return the
login form with a response of HTTP 200, which also helps when
reloading in the browser after a failed login attempt.  I am not worried
with the HTTP 401 when attempting to load a page as guest, because
this request most probably comes from the browser, not HTMx, and it will
show the login form as intended—even though it is not compliant, since
it does not return the WWW-Authenticate header, but this is the best i
can do given that no cookie-based authentication method has been
accepted[1].

[0]: https://www.w3.org/Bugs/Public/show_bug.cgi?id=10671#c16
[1]: https://datatracker.ietf.org/doc/id/draft-broyer-http-cookie-auth-00.html
2023-07-26 13:49:47 +02:00
jordi fita mas 2f3fc8812d Include the locale inside the User struct
The locale is completely dependent on the user, as much as its email or
CSRF token, so it does not make much sense to have it in a separate
variable: for different users we might have to use different locales.
Also, this means one less variable to pass to handlers, that most of
them will need the user at some point or another (i.e., to render its
profile icon).

The thing is that i can not import `app.User` from the template package
because it would create an import cycle. I created the `auth` package
just because of that.

I thought that the login code would be better moved to the auth package
as well, but of course then i would reintroduce the import cycle between
auth and template.
2023-07-26 12:08:59 +02:00
jordi fita mas 403c27f1e1 Add the skeleton of the web application
It does nothing more than to server a single page that does nothing
interesting.

This time i do not use a router.  Instead, i am trying out a technique
i have seen in an article[0] that i have tried in other, smaller,
projects and seems to work surprisingly well: it just “cuts off” the
URI path by path, passing the request from handler to handler until
it finds its way to a handler that actually serves the request.

That helps to loosen the coupling between the application and lower
handlers, and makes dependencies explicit, because i need to pass the
locale, company, etc. down instead of storing them in contexts.  Let’s
see if i do not regret it on a later date.

I also made a lot more packages that in Numerus.  In Numerus i actually
only have the single pkg package, and it works, kind of, but i notice
how i name my methods to avoid clashing instead of using packages for
that.  That is, instead of pkg.NewApp i now have app.New.

Initially i thought that Locale should be inside app, but then there was
a circular dependency between app and template.  That is why i created a
separate package, but now i am wondering if template should be inside
app too, but then i would have app.MustRenderTemplate instead of
template.MustRender.

The CSS is the most bare-bones file i could write because i am focusing
in markup right now; Oriol will fill in the file once the application is
working.

[0]: https://blog.merovius.de/posts/2017-06-18-how-not-to-use-an-http-router/
2023-07-23 00:11:00 +02:00