Initial commit. Working login, example UI with prototype and demo, connections to DB and DAV, local and prod setups.

This commit is contained in:
2025-10-12 22:13:19 +01:00
commit 708b741a32
138 changed files with 7797 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
//go:build dev
// +build dev
package dav
import (
"context"
"fmt"
"log"
"os"
"github.com/jackc/pgx/v5/pgxpool"
)
var Service *BaseService
func init() {
if err := connect(); err != nil {
log.Fatalf("failed to initialize dev service: %v", err)
}
}
func connect() error {
dsn := fmt.Sprintf(
"postgres://%s:%s@localhost:5432/%s?sslmode=disable",
getEnv("POSTGRES_USER"),
getEnv("POSTGRES_PASSWORD"),
getEnv("POSTGRES_DB"),
)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
return err
}
Service = newBaseService(pool)
return testDB(pool)
}
func testDB(pool *pgxpool.Pool) error {
var n int
err := pool.QueryRow(context.Background(), "SELECT 1").Scan(&n)
if err != nil || n != 1 {
return fmt.Errorf("db test failed: %w", err)
}
return nil
}
func getEnv(key string) string {
if v := os.Getenv(key); v != "" {
return v
}
log.Fatalf("FATAL: environment variable %s not set", key)
return ""
}
+55
View File
@@ -0,0 +1,55 @@
//go:build !dev
// +build !dev
package dav
import (
"context"
"fmt"
"log"
"os"
"github.com/jackc/pgx/v5/pgxpool"
)
var Service *BaseService
func init() {
if err := connect(); err != nil {
log.Fatalf("failed to initialize prod service: %v", err)
}
}
func connect() error {
dsn := fmt.Sprintf(
"postgres://%s:%s@%s:5432/%s",
getEnv("POSTGRES_USER"),
getEnv("POSTGRES_PASSWORD"),
getEnv("POSTGRES_HOST"),
getEnv("POSTGRES_DB"),
)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
return err
}
Service = newBaseService(pool)
return testDB(pool)
}
func testDB(pool *pgxpool.Pool) error {
var n int
err := pool.QueryRow(context.Background(), "SELECT 1").Scan(&n)
if err != nil || n != 1 {
return fmt.Errorf("db test failed: %w", err)
}
return nil
}
func getEnv(key string) string {
if v := os.Getenv(key); v != "" {
return v
}
log.Fatalf("FATAL: environment variable %s not set", key)
return ""
}
+276
View File
@@ -0,0 +1,276 @@
package dav
import (
"context"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// BaseService holds shared DB connection
type BaseService struct {
db *pgxpool.Pool
}
// newBaseService returns a new BaseService instance
func newBaseService(db *pgxpool.Pool) *BaseService {
return &BaseService{db: db}
}
// ============================================================================
// Calendar Functions
// ============================================================================
func (s *BaseService) ListEventsForMonth(year int, month time.Month) ([]CalendarEvent, error) {
start := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
end := start.AddDate(0, 1, 0).Add(-time.Second)
query := `
SELECT id, calendarid, uri, calendardata, lastmodified, etag, size, componenttype,
firstoccurence, lastoccurence, uid
FROM dav_calendarobjects
WHERE firstoccurence >= $1 AND firstoccurence <= $2
ORDER BY firstoccurence
`
return s.queryEventsWithContacts(query, start.Unix(), end.Unix())
}
func (s *BaseService) ListEventsBetween(start, end time.Time) ([]CalendarEvent, error) {
query := `
SELECT id, calendarid, uri, calendardata, lastmodified, etag, size, componenttype,
firstoccurence, lastoccurence, uid
FROM dav_calendarobjects
WHERE firstoccurence >= $1 AND firstoccurence <= $2
ORDER BY firstoccurence
`
return s.queryEventsWithContacts(query, start.Unix(), end.Unix())
}
func (s *BaseService) ListEventsTomorrow() ([]CalendarEvent, error) {
now := time.Now()
tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
dayAfter := tomorrow.Add(24 * time.Hour)
return s.ListEventsBetween(tomorrow, dayAfter)
}
func (s *BaseService) ListEventsThisWeek() ([]CalendarEvent, error) {
now := time.Now()
weekday := int(now.Weekday())
if weekday == 0 { // Sunday
weekday = 7
}
monday := now.AddDate(0, 0, -weekday+1)
monday = time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, now.Location())
sunday := monday.AddDate(0, 0, 7)
return s.ListEventsBetween(monday, sunday)
}
// ============================================================================
// Contact Functions
// ============================================================================
func (s *BaseService) GetContactByURI(addressBookID int, uri string) (*Contact, error) {
query := `
SELECT id, addressbookid, uri, carddata, lastmodified, etag, size
FROM dav_cards
WHERE addressbookid = $1 AND uri = $2
`
var c Contact
err := s.db.QueryRow(context.Background(), query, addressBookID, uri).Scan(
&c.ID, &c.AddressBookID, &c.URI, &c.CardData, &c.LastModified, &c.Etag, &c.Size,
)
if err != nil {
return nil, fmt.Errorf("contact not found: %w", err)
}
return &c, nil
}
func (s *BaseService) ListAllContacts() ([]Contact, error) {
query := `SELECT id, addressbookid, uri, carddata, lastmodified, etag, size FROM dav_cards ORDER BY lastmodified DESC`
rows, err := s.db.Query(context.Background(), query)
if err != nil {
return nil, err
}
defer rows.Close()
var contacts []Contact
for rows.Next() {
var c Contact
if err := rows.Scan(&c.ID, &c.AddressBookID, &c.URI, &c.CardData, &c.LastModified, &c.Etag, &c.Size); err != nil {
return nil, err
}
contacts = append(contacts, c)
}
return contacts, nil
}
func (s *BaseService) ListRecentContacts(days int) ([]Contact, error) {
cutoff := time.Now().AddDate(0, 0, -days).Unix()
query := `SELECT id, addressbookid, uri, carddata, lastmodified, etag, size FROM dav_cards WHERE lastmodified >= $1 ORDER BY lastmodified DESC`
rows, err := s.db.Query(context.Background(), query, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var contacts []Contact
for rows.Next() {
var c Contact
if err := rows.Scan(&c.ID, &c.AddressBookID, &c.URI, &c.CardData, &c.LastModified, &c.Etag, &c.Size); err != nil {
return nil, err
}
contacts = append(contacts, c)
}
return contacts, nil
}
// ============================================================================
// Helper Methods
// ============================================================================
func (s *BaseService) queryEventsWithContacts(query string, args ...interface{}) ([]CalendarEvent, error) {
rows, err := s.db.Query(context.Background(), query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var events []CalendarEvent
for rows.Next() {
var e CalendarEvent
if err := rows.Scan(
&e.ID, &e.CalendarID, &e.URI, &e.CalendarData, &e.LastModified, &e.Etag, &e.Size,
&e.ComponentType, &e.FirstOccurence, &e.LastOccurence, &e.UID,
); err != nil {
return nil, err
}
e.ContactURIs = extractContactURIsFromICalendar(e.CalendarData)
events = append(events, e)
}
return events, nil
}
func extractContactURIsFromICalendar(icalData string) []string {
var uris []string
for _, line := range strings.Split(icalData, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "ATTENDEE") {
parts := strings.Split(line, ":")
if len(parts) >= 2 {
uris = append(uris, strings.TrimSpace(parts[len(parts)-1]))
}
}
}
return uris
}
// CreateContact adds a new contact to an address book
func (s *BaseService) CreateContact(addressBookID int, userID string, input ContactInput) error {
now := time.Now().Unix()
uri := fmt.Sprintf("%s.vcf", userID)
cardData := GenerateVCard(input)
query := `
INSERT INTO dav_cards (addressbookid, uri, carddata, lastmodified, etag, size)
VALUES ($1, $2, $3, $4, $5, $6)
`
_, err := s.db.Exec(context.Background(), query,
addressBookID,
uri,
cardData,
now,
fmt.Sprintf("%d", now),
len(cardData),
)
return err
}
// UpdateContact updates an existing contact by URI
func (s *BaseService) UpdateContact(addressBookID int, uri string, input ContactInput) error {
now := time.Now().Unix()
cardData := GenerateVCard(input)
query := `
UPDATE dav_cards
SET carddata = $1, lastmodified = $2, etag = $3, size = $4
WHERE addressbookid = $5 AND uri = $6
`
_, err := s.db.Exec(context.Background(), query, cardData, now, fmt.Sprintf("%d", now), len(cardData), addressBookID, uri)
return err
}
// DeleteContact deletes a contact by URI
func (s *BaseService) DeleteContact(addressBookID int, uri string) error {
query := `DELETE FROM dav_cards WHERE addressbookid = $1 AND uri = $2`
_, err := s.db.Exec(context.Background(), query, addressBookID, uri)
return err
}
// CreateEvent adds a new event to a calendar
func (s *BaseService) CreateEvent(calendarID int, input EventInput) error {
uid := fmt.Sprintf("%d@example.com", time.Now().UnixNano())
now := time.Now().Unix()
calendarData := GenerateICalEvent(input)
query := `
INSERT INTO dav_calendarobjects
(calendarid, uri, calendardata, lastmodified, etag, size, componenttype,
firstoccurence, lastoccurence, uid)
VALUES ($1, $2, $3, $4, $5, $6, 'VEVENT', $7, $8, $9)
`
_, err := s.db.Exec(context.Background(), query,
calendarID,
uid+".ics",
calendarData,
now,
fmt.Sprintf("%d", now),
len(calendarData),
input.Start.Unix(),
input.End.Unix(),
uid,
)
return err
}
// UpdateEvent updates an existing calendar event by UID
func (s *BaseService) UpdateEvent(calendarID int, uid string, input EventInput) error {
now := time.Now().Unix()
calendarData := GenerateICalEvent(input)
query := `
UPDATE dav_calendarobjects
SET calendardata = $1, lastmodified = $2, etag = $3, size = $4,
firstoccurence = $5, lastoccurence = $6
WHERE calendarid = $7 AND uid = $8
`
_, err := s.db.Exec(context.Background(), query,
calendarData, now, fmt.Sprintf("%d", now), len(calendarData),
input.Start.Unix(), input.End.Unix(),
calendarID, uid,
)
return err
}
// DeleteEvent deletes an event by UID
func (s *BaseService) DeleteEvent(calendarID int, uid string) error {
query := `DELETE FROM dav_calendarobjects WHERE calendarid = $1 AND uid = $2`
_, err := s.db.Exec(context.Background(), query, calendarID, uid)
return err
}
// ListEventsForContactSQL returns all calendar events where the given contact URI is an attendee (SQL optimized)
func (s *BaseService) ListEventsForContact(contactURI string) ([]CalendarEvent, error) {
// Use pattern matching to find events containing the contact URI in ATTENDEE lines
likePattern := "%" + contactURI + "%"
query := `
SELECT id, calendarid, uri, calendardata, lastmodified, etag, size, componenttype,
firstoccurence, lastoccurence, uid
FROM dav_calendarobjects
WHERE calendardata LIKE $1
ORDER BY firstoccurence
`
return s.queryEventsWithContacts(query, likePattern)
}
+187
View File
@@ -0,0 +1,187 @@
package dav
import (
"fmt"
"time"
)
// ============================================================================
// Calendar Types
// ============================================================================
type Calendar struct {
ID int `json:"id"`
PrincipalURI string `json:"principaluri"`
DisplayName string `json:"displayname"`
URI string `json:"uri"`
Description string `json:"description"`
CalendarOrder int `json:"calendarorder"`
CalendarColor string `json:"calendarcolor"`
Components string `json:"components"`
}
type CalendarEvent struct {
ID int `json:"id"`
CalendarID int `json:"calendarid"`
URI string `json:"uri"`
CalendarData string `json:"calendardata"`
LastModified int64 `json:"lastmodified"`
Etag string `json:"etag"`
Size int `json:"size"`
ComponentType string `json:"componenttype"`
FirstOccurence int64 `json:"firstoccurence"`
LastOccurence int64 `json:"lastoccurence"`
UID string `json:"uid"`
ContactURIs []string `json:"contact_uris"` // Extracted from ATTENDEE fields
}
type EventInput struct {
Summary string
Description string
Location string
Start time.Time
End time.Time
AllDay bool
ContactURIs []string // URIs of contacts to attach as attendees
}
// ============================================================================
// CardDAV Types
// ============================================================================
type AddressBook struct {
ID int `json:"id"`
PrincipalURI string `json:"principaluri"`
DisplayName string `json:"displayname"`
URI string `json:"uri"`
Description string `json:"description"`
SyncToken int `json:"synctoken"`
}
type Contact struct {
ID int `json:"id"`
AddressBookID int `json:"addressbookid"`
URI string `json:"uri"`
CardData string `json:"carddata"`
LastModified int64 `json:"lastmodified"`
Etag string `json:"etag"`
Size int `json:"size"`
}
type ContactInput struct {
UserID string
FirstName string
LastName string
Email string
Phone string
DOB string
}
// ============================================================================
// Helper Functions
// ============================================================================
// GenerateICalEvent creates iCalendar format for UK timezone
func GenerateICalEvent(input EventInput) string {
uid := fmt.Sprintf("%d@example.com", time.Now().UnixNano())
dtstamp := time.Now().UTC().Format("20060102T150405Z")
var dtstart, dtend string
if input.AllDay {
dtstart = fmt.Sprintf("DTSTART;VALUE=DATE:%s", input.Start.Format("20060102"))
dtend = fmt.Sprintf("DTEND;VALUE=DATE:%s", input.End.Format("20060102"))
} else {
dtstart = fmt.Sprintf("DTSTART;TZID=Europe/London:%s", input.Start.Format("20060102T150405"))
dtend = fmt.Sprintf("DTEND;TZID=Europe/London:%s", input.End.Format("20060102T150405"))
}
// Build attendees section
attendees := ""
for _, contactURI := range input.ContactURIs {
attendees += fmt.Sprintf("ATTENDEE;CN=%s:%s\n", contactURI, contactURI)
}
ical := fmt.Sprintf(`BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Your App//EN
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/London
BEGIN:DAYLIGHT
TZOFFSETFROM:+0000
TZOFFSETTO:+0100
TZNAME:BST
DTSTART:19700329T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0100
TZOFFSETTO:+0000
TZNAME:GMT
DTSTART:19701025T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:%s
DTSTAMP:%s
%s
%s
SUMMARY:%s
DESCRIPTION:%s
LOCATION:%s
%sSEQUENCE:0
STATUS:CONFIRMED
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR`, uid, dtstamp, dtstart, dtend,
escapeICalText(input.Summary),
escapeICalText(input.Description),
escapeICalText(input.Location),
attendees)
return ical
}
// GenerateVCard creates vCard format (version 3.0)
func GenerateVCard(input ContactInput) string {
vcard := fmt.Sprintf(`BEGIN:VCARD
VERSION:3.0
UID:%s
FN:%s %s
N:%s;%s;;;
EMAIL;TYPE=INTERNET:%s
TEL;TYPE=CELL:%s
BDAY:%s
REV:%s
END:VCARD`,
input.UserID,
input.FirstName, input.LastName,
input.LastName, input.FirstName,
input.Email,
input.Phone,
input.DOB,
time.Now().UTC().Format("20060102T150405Z"))
return vcard
}
func escapeICalText(text string) string {
text = replaceAll(text, "\\", "\\\\")
text = replaceAll(text, "\n", "\\n")
text = replaceAll(text, ",", "\\,")
text = replaceAll(text, ";", "\\;")
return text
}
func replaceAll(s, old, new string) string {
result := ""
for _, char := range s {
if string(char) == old {
result += new
} else {
result += string(char)
}
}
return result
}