CI / Nginx config check (push) Successful in 13s
CI / Env docs check (push) Successful in 15s
CI / Docker compose check (push) Successful in 15s
CI / Frontend major deps (push) Failing after 24s
CI / Frontend deps check (push) Successful in 30s
CI / Secrets scan (push) Successful in 38s
CI / Go build (push) Successful in 39s
CI / Frontend build (push) Successful in 1m3s
CI / Knip (push) Successful in 45s
CI / Go vet (prod) (push) Failing after 1m42s
CI / Frontend a11y check (push) Successful in 2m34s
CI / Go vet (dev) (push) Successful in 2m29s
CI / Staticcheck (prod) (push) Failing after 2m38s
CI / go mod tidy (push) Successful in 1m3s
CI / Staticcheck (dev) (push) Successful in 2m55s
CI / Frontend QC (audit) (push) Successful in 51s
CI / golangci-lint (push) Successful in 3m22s
CI / Go vulnerabilities (push) Successful in 1m26s
CI / Frontend QC (typecheck) (push) Successful in 2m18s
CI / Security scan (prod) (push) Successful in 4m18s
CI / Security scan (dev) (push) Successful in 4m40s
CI / Tests (prod) (push) Has been skipped
CI / Tests (dev) (push) Has been skipped
CI / Race (prod) (push) Has been skipped
CI / Race (dev) (push) Has been skipped
CI / Frontend QC (lint) (push) Successful in 2m18s
CI / Svelte strict check (push) Successful in 43s
New test files cover previously untested paths across DAV, validators, S3, Square, mw, bookings, user, and payments packages. Includes mock fix: HoldCheckouts flag on MockClient allows tests to pause auto-complete goroutine for testing PENDING checkout states. Coverage: 50.4% → 65.0% (+14.6pp)
368 lines
10 KiB
Go
368 lines
10 KiB
Go
package dav
|
|
|
|
import (
|
|
"context"
|
|
"crussell/clock"
|
|
"fmt"
|
|
"log/slog"
|
|
"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 := clock.Now()
|
|
tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
|
dayAfter := tomorrow.Add(24 * time.Hour)
|
|
return s.ListEventsBetween(tomorrow, dayAfter)
|
|
}
|
|
|
|
func (s *BaseService) ListEventsThisWeek() ([]CalendarEvent, error) {
|
|
now := clock.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, time.UTC)
|
|
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 := clock.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 ...any) ([]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.SplitSeq(icalData, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "ATTENDEE") {
|
|
_, value, ok := strings.Cut(line, ":")
|
|
if ok {
|
|
uris = append(uris, strings.TrimSpace(value))
|
|
}
|
|
}
|
|
}
|
|
return uris
|
|
}
|
|
|
|
// CreateContact adds a new contact to an address book
|
|
func (s *BaseService) CreateContact(addressBookID int, userID string, input ContactInput) error {
|
|
if s.db == nil {
|
|
return nil // No DB configured, skip CardDAV sync
|
|
}
|
|
|
|
now := clock.Now().Unix()
|
|
uri := fmt.Sprintf("%s.vcf", userID)
|
|
cardData := GenerateVCard(input)
|
|
|
|
tx, err := s.db.Begin(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(context.Background()); err != nil && err.Error() != "tx is closed" {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
query := `
|
|
INSERT INTO dav_cards (addressbookid, uri, carddata, lastmodified, etag, size)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
`
|
|
_, err = tx.Exec(context.Background(), query,
|
|
addressBookID,
|
|
uri,
|
|
cardData,
|
|
now,
|
|
fmt.Sprintf("%d", now),
|
|
len(cardData),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit(context.Background())
|
|
}
|
|
|
|
// UpdateContact updates an existing contact by URI
|
|
func (s *BaseService) UpdateContact(addressBookID int, uri string, input ContactInput) error {
|
|
now := clock.Now().Unix()
|
|
cardData := GenerateVCard(input)
|
|
|
|
tx, err := s.db.Begin(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(context.Background()); err != nil && err.Error() != "tx is closed" {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
query := `
|
|
UPDATE dav_cards
|
|
SET carddata = $1, lastmodified = $2, etag = $3, size = $4
|
|
WHERE addressbookid = $5 AND uri = $6
|
|
`
|
|
_, err = tx.Exec(context.Background(), query, cardData, now, fmt.Sprintf("%d", now), len(cardData), addressBookID, uri)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit(context.Background())
|
|
}
|
|
|
|
// DeleteContact deletes a contact by URI
|
|
func (s *BaseService) DeleteContact(addressBookID int, uri string) error {
|
|
tx, err := s.db.Begin(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(context.Background()); err != nil && err.Error() != "tx is closed" {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
query := `DELETE FROM dav_cards WHERE addressbookid = $1 AND uri = $2`
|
|
_, err = tx.Exec(context.Background(), query, addressBookID, uri)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit(context.Background())
|
|
}
|
|
|
|
// CreateEvent adds a new event to a calendar
|
|
func (s *BaseService) CreateEvent(calendarID int, input EventInput) error {
|
|
uid := fmt.Sprintf("%d@example.com", clock.Now().UnixNano())
|
|
now := clock.Now().Unix()
|
|
calendarData := GenerateICalEvent(input)
|
|
|
|
tx, err := s.db.Begin(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(context.Background()); err != nil && err.Error() != "tx is closed" {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
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 = tx.Exec(context.Background(), query,
|
|
calendarID,
|
|
uid+".ics",
|
|
calendarData,
|
|
now,
|
|
fmt.Sprintf("%d", now),
|
|
len(calendarData),
|
|
input.Start.Unix(),
|
|
input.End.Unix(),
|
|
uid,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit(context.Background())
|
|
}
|
|
|
|
// UpdateEvent updates an existing calendar event by UID
|
|
func (s *BaseService) UpdateEvent(calendarID int, uid string, input EventInput) error {
|
|
now := clock.Now().Unix()
|
|
calendarData := GenerateICalEvent(input)
|
|
|
|
tx, err := s.db.Begin(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(context.Background()); err != nil && err.Error() != "tx is closed" {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
query := `
|
|
UPDATE dav_calendarobjects
|
|
SET calendardata = $1, lastmodified = $2, etag = $3, size = $4,
|
|
firstoccurence = $5, lastoccurence = $6
|
|
WHERE calendarid = $7 AND uid = $8
|
|
`
|
|
_, err = tx.Exec(context.Background(), query,
|
|
calendarData, now, fmt.Sprintf("%d", now), len(calendarData),
|
|
input.Start.Unix(), input.End.Unix(),
|
|
calendarID, uid,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit(context.Background())
|
|
}
|
|
|
|
// DeleteEvent deletes an event by UID
|
|
func (s *BaseService) DeleteEvent(calendarID int, uid string) error {
|
|
tx, err := s.db.Begin(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(context.Background()); err != nil && err.Error() != "tx is closed" {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
query := `DELETE FROM dav_calendarobjects WHERE calendarid = $1 AND uid = $2`
|
|
_, err = tx.Exec(context.Background(), query, calendarID, uid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit(context.Background())
|
|
}
|
|
|
|
// 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)
|
|
}
|