diff --git a/backend/internal/dav/service_dev.go b/backend/internal/dav/service_dev.go index 1da9638..3d4d7a5 100644 --- a/backend/internal/dav/service_dev.go +++ b/backend/internal/dav/service_dev.go @@ -6,7 +6,6 @@ package dav import ( "context" "fmt" - "log" "os" "github.com/jackc/pgx/v5/pgxpool" @@ -20,13 +19,13 @@ func init() { if os.Getenv("GO_TESTING") != "" { return } - log.Fatalf("failed to initialize dev service: %v", err) + panic("failed to initialize dev service: " + err.Error()) } } func connect() error { dsn := fmt.Sprintf( - "postgres://%s:%s@localhost:5432/%s?sslmode=disable&require_auth=scram-sha-256", + "postgres://%s:%s@localhost:5432/%s?sslmode=disable&timezone=UTC&require_auth=scram-sha-256", getEnv("POSTGRES_USER"), getEnv("POSTGRES_PASSWORD"), getEnv("POSTGRES_DB"), diff --git a/backend/internal/dav/service_prod.go b/backend/internal/dav/service_prod.go index 3159092..204c387 100644 --- a/backend/internal/dav/service_prod.go +++ b/backend/internal/dav/service_prod.go @@ -6,7 +6,6 @@ package dav import ( "context" "fmt" - "log" "os" "github.com/jackc/pgx/v5/pgxpool" @@ -20,13 +19,13 @@ func init() { if os.Getenv("GO_TESTING") != "" { return } - log.Fatalf("failed to initialize prod service: %v", err) + panic("failed to initialize prod service: " + err.Error()) } } func connect() error { dsn := fmt.Sprintf( - "postgres://%s:%s@%s:5432/%s?require_auth=scram-sha-256", + "postgres://%s:%s@%s:5432/%s?timezone=UTC&require_auth=scram-sha-256", getEnv("POSTGRES_USER"), getEnv("POSTGRES_PASSWORD"), getEnv("POSTGRES_HOST"), diff --git a/backend/internal/dav/shared.go b/backend/internal/dav/shared.go index 02a5056..275ad08 100644 --- a/backend/internal/dav/shared.go +++ b/backend/internal/dav/shared.go @@ -1,6 +1,7 @@ package dav import ( + "crussell/clock" "context" "fmt" "strings" @@ -49,20 +50,20 @@ func (s *BaseService) ListEventsBetween(start, end time.Time) ([]CalendarEvent, } 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()) + 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 := time.Now() + 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, now.Location()) + 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) } @@ -107,7 +108,7 @@ func (s *BaseService) ListAllContacts() ([]Contact, error) { } func (s *BaseService) ListRecentContacts(days int) ([]Contact, error) { - cutoff := time.Now().AddDate(0, 0, -days).Unix() + 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 { @@ -172,15 +173,21 @@ func (s *BaseService) CreateContact(addressBookID int, userID string, input Cont return nil // No DB configured, skip CardDAV sync } - now := time.Now().Unix() + 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 tx.Rollback(context.Background()) + 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, + _, err = tx.Exec(context.Background(), query, addressBookID, uri, cardData, @@ -188,42 +195,73 @@ func (s *BaseService) CreateContact(addressBookID int, userID string, input Cont fmt.Sprintf("%d", now), len(cardData), ) - return err + 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 := time.Now().Unix() + now := clock.Now().Unix() cardData := GenerateVCard(input) + + tx, err := s.db.Begin(context.Background()) + if err != nil { + return err + } + defer tx.Rollback(context.Background()) + 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 + _, 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 tx.Rollback(context.Background()) + query := `DELETE FROM dav_cards WHERE addressbookid = $1 AND uri = $2` - _, err := s.db.Exec(context.Background(), query, addressBookID, uri) - return err + _, 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", time.Now().UnixNano()) - now := time.Now().Unix() + 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 tx.Rollback(context.Background()) + 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, + _, err = tx.Exec(context.Background(), query, calendarID, uid+".ics", calendarData, @@ -234,33 +272,57 @@ func (s *BaseService) CreateEvent(calendarID int, input EventInput) error { input.End.Unix(), uid, ) - return err + 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 := time.Now().Unix() + now := clock.Now().Unix() calendarData := GenerateICalEvent(input) + tx, err := s.db.Begin(context.Background()) + if err != nil { + return err + } + defer tx.Rollback(context.Background()) + 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, + _, err = tx.Exec(context.Background(), query, calendarData, now, fmt.Sprintf("%d", now), len(calendarData), input.Start.Unix(), input.End.Unix(), calendarID, uid, ) - return err + 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 tx.Rollback(context.Background()) + query := `DELETE FROM dav_calendarobjects WHERE calendarid = $1 AND uid = $2` - _, err := s.db.Exec(context.Background(), query, calendarID, uid) - return err + _, 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) diff --git a/backend/internal/dav/types.go b/backend/internal/dav/types.go index 8f87dd9..ccb848e 100644 --- a/backend/internal/dav/types.go +++ b/backend/internal/dav/types.go @@ -1,6 +1,7 @@ package dav import ( + "crussell/clock" "fmt" "time" ) @@ -81,18 +82,23 @@ type ContactInput struct { // Helper Functions // ============================================================================ -// GenerateICalEvent creates iCalendar format for UK timezone +// GenerateICalEvent creates iCalendar format. +// All times stored in the system are UTC equivalents of Europe/London +// wall-clock times (e.g., 10:00 BST is stored as 09:00 UTC). The generated +// iCal uses UTC-flagged DTSTART/DTEND (with Z suffix) so calendar apps +// correctly display the intended wall-clock time regardless of the reader's +// timezone. func GenerateICalEvent(input EventInput) string { - uid := fmt.Sprintf("%d@example.com", time.Now().UnixNano()) - dtstamp := time.Now().UTC().Format("20060102T150405Z") + uid := fmt.Sprintf("%d@example.com", clock.Now().UnixNano()) + dtstamp := clock.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")) + dtstart = fmt.Sprintf("DTSTART:%sZ", input.Start.UTC().Format("20060102T150405")) + dtend = fmt.Sprintf("DTEND:%sZ", input.End.UTC().Format("20060102T150405")) } // Build attendees section @@ -105,23 +111,6 @@ func GenerateICalEvent(input EventInput) string { 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 @@ -161,7 +150,7 @@ END:VCARD`, input.Email, input.Phone, input.DOB, - time.Now().UTC().Format("20060102T150405Z")) + clock.Now().UTC().Format("20060102T150405Z")) return vcard } diff --git a/backend/internal/s3/s3.go b/backend/internal/s3/s3.go index 6f2fe5c..567da22 100644 --- a/backend/internal/s3/s3.go +++ b/backend/internal/s3/s3.go @@ -18,6 +18,7 @@ type Uploader interface { Download(ctx context.Context, bucket, key string, w io.Writer) error Delete(ctx context.Context, bucket, key string) error GetURL(ctx context.Context, bucket, key string) (string, error) + HealthCheck(ctx context.Context) error } type S3Client struct { @@ -65,6 +66,15 @@ func (s *S3Client) GetURL(ctx context.Context, bucket, key string) (string, erro return fmt.Sprintf("%s/%s/%s", s.publicURL, bucket, key), nil } +func (s *S3Client) HealthCheck(_ context.Context) error { + if s.endpoint == "" { + return fmt.Errorf("S3 client not initialized: R2_ENDPOINT not set") + } + // Production stub — can't perform a real check without the SDK. + // Actual operations (Upload, Download, Delete) will return errors. + return nil +} + func getEnv(key, fallback string) string { if val := os.Getenv(key); val != "" { return val @@ -72,6 +82,5 @@ func getEnv(key, fallback string) string { if fallback != "" { return fallback } - log.Fatal("FATAL: Environment variable not set:", key) - return "" + panic("Environment variable not set: " + key) } diff --git a/backend/internal/s3/s3_dev.go b/backend/internal/s3/s3_dev.go index a88ec2e..3aeb712 100644 --- a/backend/internal/s3/s3_dev.go +++ b/backend/internal/s3/s3_dev.go @@ -23,6 +23,7 @@ type Uploader interface { Download(ctx context.Context, bucket, key string, w io.Writer) error Delete(ctx context.Context, bucket, key string) error GetURL(ctx context.Context, bucket, key string) (string, error) + HealthCheck(ctx context.Context) error } type S3Client struct { @@ -202,3 +203,13 @@ func (s *S3Client) Delete(ctx context.Context, bucket, key string) error { func (s *S3Client) GetURL(ctx context.Context, bucket, key string) (string, error) { return fmt.Sprintf("%s/%s/%s", s.publicURL, bucket, key), nil } + +func (s *S3Client) HealthCheck(ctx context.Context) error { + _, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{ + Bucket: aws.String(s.bucket), + }) + if err != nil { + return fmt.Errorf("S3 bucket %q is not accessible (check S3_PUBLIC_URL / RUSTFS_ENDPOINT config): %w", s.bucket, err) + } + return nil +} diff --git a/backend/internal/square/square_dev.go b/backend/internal/square/square_dev.go index c948443..33013ff 100644 --- a/backend/internal/square/square_dev.go +++ b/backend/internal/square/square_dev.go @@ -4,6 +4,7 @@ package square import ( + "crussell/clock" "context" "fmt" "log" @@ -85,7 +86,7 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (* m.mu.Lock() defer m.mu.Unlock() - paymentID := fmt.Sprintf("pay_mock_%d", time.Now().UnixNano()) + paymentID := fmt.Sprintf("pay_mock_%d", clock.Now().UnixNano()) fees := req.Amount*14/1000 + 25 // online rate: 1.4% + 25p result := &PaymentResult{ @@ -107,7 +108,7 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (* func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) { log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, tipEnabled=%v, reference=%s", req.Amount, req.TipEnabled, req.ReferenceID) - checkoutID := fmt.Sprintf("chk_mock_%d", time.Now().UnixNano()) + checkoutID := fmt.Sprintf("chk_mock_%d", clock.Now().UnixNano()) result := &CheckoutResult{ ID: checkoutID, Status: "PENDING", @@ -118,12 +119,17 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) m.mu.Unlock() go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic recovered in Square mock payment processing: %v", r) + } + }() mockSleep(3 * time.Second) m.mu.Lock() defer m.mu.Unlock() - paymentID := fmt.Sprintf("pay_%d", time.Now().UnixNano()) + paymentID := fmt.Sprintf("pay_%d", clock.Now().UnixNano()) amount := req.Amount tipAmount := int64(0) if req.TipEnabled { @@ -184,7 +190,7 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (* m.mu.Lock() defer m.mu.Unlock() - refundID := fmt.Sprintf("ref_mock_%d", time.Now().UnixNano()) + refundID := fmt.Sprintf("ref_mock_%d", clock.Now().UnixNano()) amount := req.Amount if amount == 0 { if payment, ok := m.payments[req.PaymentID]; ok { @@ -212,7 +218,7 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken str m.cards[userID] = make(map[string]*CardOnFile) } - cardID := fmt.Sprintf("mock_card_%d", time.Now().UnixNano()) + cardID := fmt.Sprintf("mock_card_%d", clock.Now().UnixNano()) card := &CardOnFile{ ID: cardID, CardID: "cfa_" + cardID, @@ -220,7 +226,7 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken str Last4: "4242", ExpMonth: 12, ExpYear: 2030, - Fingerprint: fmt.Sprintf("fp_%d", time.Now().UnixNano()), + Fingerprint: fmt.Sprintf("fp_%d", clock.Now().UnixNano()), IsDefault: len(m.cards[userID]) == 0, } m.cards[userID][cardID] = card @@ -238,7 +244,7 @@ func (m *MockClient) CreateCardOnFileRaw(ctx context.Context, userID, cardNumber m.cards[userID] = make(map[string]*CardOnFile) } - cardID := fmt.Sprintf("mock_card_%d", time.Now().UnixNano()) + cardID := fmt.Sprintf("mock_card_%d", clock.Now().UnixNano()) last4 := cardNumber[len(cardNumber)-4:] brands := map[string]string{"4": "VISA", "5": "MASTERCARD", "3": "AMEX", "6": "DISCOVER"} brand := brands[string(cardNumber[0])] @@ -253,7 +259,7 @@ func (m *MockClient) CreateCardOnFileRaw(ctx context.Context, userID, cardNumber Last4: last4, ExpMonth: expMonth, ExpYear: expYear, - Fingerprint: fmt.Sprintf("fp_%d", time.Now().UnixNano()), + Fingerprint: fmt.Sprintf("fp_%d", clock.Now().UnixNano()), IsDefault: len(m.cards[userID]) == 0, } m.cards[userID][cardID] = card