Files
mailgosend/internal/db/caldav.go
T
2026-05-22 06:06:44 +00:00

326 lines
11 KiB
Go

// Package db — CalDAV and CardDAV database operations.
package db
import (
"context"
"database/sql"
"fmt"
"time"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
)
// ---- CalDAV ----
// ListCalendars returns all calendars for a user.
func (d *DB) ListCalendars(ctx context.Context, userID int64) ([]*models.Calendar, error) {
rows, err := d.db.QueryContext(ctx, `
SELECT id, user_id, name, description, color, timezone, sync_token, created_at
FROM calendars WHERE user_id=? ORDER BY name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.Calendar
for rows.Next() {
c := &models.Calendar{}
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.Description, &c.Color, &c.Timezone, &c.SyncToken, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// GetCalendarByID returns a calendar by ID.
func (d *DB) GetCalendarByID(ctx context.Context, id int64) (*models.Calendar, error) {
row := d.db.QueryRowContext(ctx,
"SELECT id, user_id, name, description, color, timezone, sync_token, created_at FROM calendars WHERE id=?", id)
c := &models.Calendar{}
err := row.Scan(&c.ID, &c.UserID, &c.Name, &c.Description, &c.Color, &c.Timezone, &c.SyncToken, &c.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return c, err
}
// CreateCalendar inserts a new calendar and returns the ID.
func (d *DB) CreateCalendar(ctx context.Context, userID int64, name, description, color, timezone string) (int64, error) {
if color == "" {
color = "#4CAF50"
}
if timezone == "" {
timezone = "UTC"
}
res, err := d.db.ExecContext(ctx,
"INSERT INTO calendars (user_id, name, description, color, timezone, sync_token, created_at) VALUES (?,?,?,?,?,1,?)",
userID, name, description, color, timezone, time.Now().UTC())
if err != nil {
return 0, fmt.Errorf("create calendar: %w", err)
}
return res.LastInsertId()
}
// EnsureDefaultCalendar creates a "Personal" calendar for a user if none exists.
func (d *DB) EnsureDefaultCalendar(ctx context.Context, userID int64) (*models.Calendar, error) {
cals, err := d.ListCalendars(ctx, userID)
if err != nil {
return nil, err
}
if len(cals) > 0 {
return cals[0], nil
}
id, err := d.CreateCalendar(ctx, userID, "Personal", "", "#4CAF50", "UTC")
if err != nil {
return nil, err
}
return d.GetCalendarByID(ctx, id)
}
// DeleteCalendar removes a calendar and all its events.
func (d *DB) DeleteCalendar(ctx context.Context, calendarID int64) error {
_, err := d.db.ExecContext(ctx, "DELETE FROM calendars WHERE id=?", calendarID)
return err
}
// UpdateCalendar updates calendar metadata.
func (d *DB) UpdateCalendar(ctx context.Context, calendarID int64, name, description, color, timezone string) error {
_, err := d.db.ExecContext(ctx,
"UPDATE calendars SET name=?, description=?, color=?, timezone=? WHERE id=?",
name, description, color, timezone, calendarID)
return err
}
// BumpCalendarSyncToken increments sync_token and returns the new value.
func (d *DB) BumpCalendarSyncToken(ctx context.Context, calendarID int64) (int64, error) {
_, err := d.db.ExecContext(ctx,
"UPDATE calendars SET sync_token = sync_token + 1 WHERE id=?", calendarID)
if err != nil {
return 0, err
}
var token int64
err = d.db.QueryRowContext(ctx, "SELECT sync_token FROM calendars WHERE id=?", calendarID).Scan(&token)
return token, err
}
// ---- Calendar events ----
// ListCalendarEvents returns all events in a calendar.
func (d *DB) ListCalendarEvents(ctx context.Context, calendarID int64) ([]*models.CalendarEvent, error) {
rows, err := d.db.QueryContext(ctx, `
SELECT id, calendar_id, uid, ical_enc, etag, dt_start, dt_end, summary, recurring, created_at, updated_at
FROM calendar_events WHERE calendar_id=? ORDER BY dt_start`, calendarID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.CalendarEvent
for rows.Next() {
ev := &models.CalendarEvent{}
var dtStart, dtEnd sql.NullTime
err := rows.Scan(&ev.ID, &ev.CalendarID, &ev.UID, &ev.ICalEnc, &ev.ETag,
&dtStart, &dtEnd, &ev.Summary, &ev.Recurring, &ev.CreatedAt, &ev.UpdatedAt)
if err != nil {
return nil, err
}
if dtStart.Valid {
ev.DTStart = dtStart.Time
}
if dtEnd.Valid {
ev.DTEnd = dtEnd.Time
}
out = append(out, ev)
}
return out, rows.Err()
}
// GetCalendarEvent returns one event by UID within a calendar.
func (d *DB) GetCalendarEvent(ctx context.Context, calendarID int64, uid string) (*models.CalendarEvent, error) {
row := d.db.QueryRowContext(ctx, `
SELECT id, calendar_id, uid, ical_enc, etag, dt_start, dt_end, summary, recurring, created_at, updated_at
FROM calendar_events WHERE calendar_id=? AND uid=?`, calendarID, uid)
ev := &models.CalendarEvent{}
var dtStart, dtEnd sql.NullTime
err := row.Scan(&ev.ID, &ev.CalendarID, &ev.UID, &ev.ICalEnc, &ev.ETag,
&dtStart, &dtEnd, &ev.Summary, &ev.Recurring, &ev.CreatedAt, &ev.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if dtStart.Valid {
ev.DTStart = dtStart.Time
}
if dtEnd.Valid {
ev.DTEnd = dtEnd.Time
}
return ev, nil
}
// UpsertCalendarEvent creates or replaces a calendar event.
func (d *DB) UpsertCalendarEvent(ctx context.Context, calendarID int64, uid, etag string, icalEnc []byte, dtStart, dtEnd time.Time, summary string, recurring bool) error {
now := time.Now().UTC()
_, err := d.db.ExecContext(ctx, `
INSERT INTO calendar_events (calendar_id, uid, ical_enc, etag, dt_start, dt_end, summary, recurring, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(calendar_id, uid) DO UPDATE SET
ical_enc=excluded.ical_enc, etag=excluded.etag,
dt_start=excluded.dt_start, dt_end=excluded.dt_end,
summary=excluded.summary, recurring=excluded.recurring,
updated_at=excluded.updated_at`,
calendarID, uid, icalEnc, etag, nullTime(dtStart), nullTime(dtEnd), summary, recurring, now, now)
return err
}
// DeleteCalendarEvent removes one event by UID.
func (d *DB) DeleteCalendarEvent(ctx context.Context, calendarID int64, uid string) error {
_, err := d.db.ExecContext(ctx,
"DELETE FROM calendar_events WHERE calendar_id=? AND uid=?", calendarID, uid)
return err
}
// ---- CardDAV ----
// ListAddressBooks returns all address books for a user.
func (d *DB) ListAddressBooks(ctx context.Context, userID int64) ([]*models.AddressBook, error) {
rows, err := d.db.QueryContext(ctx,
"SELECT id, user_id, name, description, color, sync_token, created_at FROM address_books WHERE user_id=? ORDER BY name", userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.AddressBook
for rows.Next() {
ab := &models.AddressBook{}
if err := rows.Scan(&ab.ID, &ab.UserID, &ab.Name, &ab.Description, &ab.Color, &ab.SyncToken, &ab.CreatedAt); err != nil {
return nil, err
}
out = append(out, ab)
}
return out, rows.Err()
}
// GetAddressBookByID returns an address book by ID.
func (d *DB) GetAddressBookByID(ctx context.Context, id int64) (*models.AddressBook, error) {
row := d.db.QueryRowContext(ctx,
"SELECT id, user_id, name, description, color, sync_token, created_at FROM address_books WHERE id=?", id)
ab := &models.AddressBook{}
err := row.Scan(&ab.ID, &ab.UserID, &ab.Name, &ab.Description, &ab.Color, &ab.SyncToken, &ab.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return ab, err
}
// CreateAddressBook inserts a new address book.
func (d *DB) CreateAddressBook(ctx context.Context, userID int64, name, description, color string) (int64, error) {
if color == "" {
color = "#4A90E2"
}
res, err := d.db.ExecContext(ctx,
"INSERT INTO address_books (user_id, name, description, color, sync_token, created_at) VALUES (?,?,?,?,1,?)",
userID, name, description, color, time.Now().UTC())
if err != nil {
return 0, fmt.Errorf("create address book: %w", err)
}
return res.LastInsertId()
}
// EnsureDefaultAddressBook creates a "Personal" address book for a user if none exists.
func (d *DB) EnsureDefaultAddressBook(ctx context.Context, userID int64) (*models.AddressBook, error) {
abs, err := d.ListAddressBooks(ctx, userID)
if err != nil {
return nil, err
}
if len(abs) > 0 {
return abs[0], nil
}
id, err := d.CreateAddressBook(ctx, userID, "Personal", "", "#4A90E2")
if err != nil {
return nil, err
}
return d.GetAddressBookByID(ctx, id)
}
// DeleteAddressBook removes an address book and all contacts.
func (d *DB) DeleteAddressBook(ctx context.Context, addressBookID int64) error {
_, err := d.db.ExecContext(ctx, "DELETE FROM address_books WHERE id=?", addressBookID)
return err
}
// BumpAddressBookSyncToken increments sync_token and returns the new value.
func (d *DB) BumpAddressBookSyncToken(ctx context.Context, addressBookID int64) (int64, error) {
_, err := d.db.ExecContext(ctx,
"UPDATE address_books SET sync_token = sync_token + 1 WHERE id=?", addressBookID)
if err != nil {
return 0, err
}
var token int64
err = d.db.QueryRowContext(ctx, "SELECT sync_token FROM address_books WHERE id=?", addressBookID).Scan(&token)
return token, err
}
// ---- Contacts ----
// ListContacts returns all contacts in an address book.
func (d *DB) ListContacts(ctx context.Context, addressBookID int64) ([]*models.Contact, error) {
rows, err := d.db.QueryContext(ctx,
"SELECT id, address_book_id, uid, vcard_enc, etag, created_at, updated_at FROM contacts WHERE address_book_id=? ORDER BY uid",
addressBookID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.Contact
for rows.Next() {
c := &models.Contact{}
if err := rows.Scan(&c.ID, &c.AddressBookID, &c.UID, &c.VCardEnc, &c.ETag, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// GetContact returns one contact by UID.
func (d *DB) GetContact(ctx context.Context, addressBookID int64, uid string) (*models.Contact, error) {
row := d.db.QueryRowContext(ctx,
"SELECT id, address_book_id, uid, vcard_enc, etag, created_at, updated_at FROM contacts WHERE address_book_id=? AND uid=?",
addressBookID, uid)
c := &models.Contact{}
err := row.Scan(&c.ID, &c.AddressBookID, &c.UID, &c.VCardEnc, &c.ETag, &c.CreatedAt, &c.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return c, err
}
// UpsertContact creates or replaces a contact.
func (d *DB) UpsertContact(ctx context.Context, addressBookID int64, uid, etag string, vcardEnc []byte) error {
now := time.Now().UTC()
_, err := d.db.ExecContext(ctx, `
INSERT INTO contacts (address_book_id, uid, vcard_enc, etag, created_at, updated_at)
VALUES (?,?,?,?,?,?)
ON CONFLICT(address_book_id, uid) DO UPDATE SET
vcard_enc=excluded.vcard_enc, etag=excluded.etag, updated_at=excluded.updated_at`,
addressBookID, uid, vcardEnc, etag, now, now)
return err
}
// DeleteContact removes a contact by UID.
func (d *DB) DeleteContact(ctx context.Context, addressBookID int64, uid string) error {
_, err := d.db.ExecContext(ctx,
"DELETE FROM contacts WHERE address_book_id=? AND uid=?", addressBookID, uid)
return err
}
// ---- helpers ----
func nullTime(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}