326 lines
11 KiB
Go
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
|
|
}
|