diff --git a/internal/db/db.go b/internal/db/db.go index 5539b0d..11eaa8d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -11,7 +11,6 @@ import ( "sort" "strconv" "strings" - "time" "git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/chat/internal/logger" @@ -51,19 +50,9 @@ func (s *Database) GetDB() *sql.DB { return s.db } -// NewChannel creates a Channel model instance with the db reference injected. -func (s *Database) NewChannel(id int64, name, topic, modes string, createdAt, updatedAt time.Time) *models.Channel { - c := &models.Channel{ - ID: id, - Name: name, - Topic: topic, - Modes: modes, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - c.SetDB(s) - - return c +// Hydrate injects the database reference into any model that embeds Base. +func (s *Database) Hydrate(m interface{ SetDB(d models.DB) }) { + m.SetDB(s) } // New creates a new Database instance and registers lifecycle hooks. diff --git a/internal/db/schema/002_tables.sql b/internal/db/schema/002_tables.sql index 91f91c8..37fd1a4 100644 --- a/internal/db/schema/002_tables.sql +++ b/internal/db/schema/002_tables.sql @@ -1,8 +1,85 @@ -CREATE TABLE IF NOT EXISTS channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - topic TEXT NOT NULL DEFAULT '', - modes TEXT NOT NULL DEFAULT '', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +-- Users: accounts and authentication +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, -- UUID + nick TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME +); + +-- Auth tokens: one user can have multiple active tokens (multiple devices) +CREATE TABLE IF NOT EXISTS auth_tokens ( + token TEXT PRIMARY KEY, -- random token string + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME, -- NULL = no expiry + last_used_at DATETIME +); +CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_id ON auth_tokens(user_id); + +-- Channels: chat rooms +CREATE TABLE IF NOT EXISTS channels ( + id TEXT PRIMARY KEY, -- UUID + name TEXT NOT NULL UNIQUE, -- #general, etc. + topic TEXT NOT NULL DEFAULT '', + modes TEXT NOT NULL DEFAULT '', -- +i, +m, +s, +t, +n + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Channel members: who is in which channel, with per-user modes +CREATE TABLE IF NOT EXISTS channel_members ( + channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + modes TEXT NOT NULL DEFAULT '', -- +o (operator), +v (voice) + joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (channel_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_channel_members_user_id ON channel_members(user_id); + +-- Messages: channel and DM history (rotated per MAX_HISTORY) +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, -- UUID + ts DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + from_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + from_nick TEXT NOT NULL, -- denormalized for history + target TEXT NOT NULL, -- #channel name or user UUID for DMs + type TEXT NOT NULL DEFAULT 'message', -- message, action, notice, join, part, quit, topic, mode, nick, system + body TEXT NOT NULL DEFAULT '', + meta TEXT NOT NULL DEFAULT '{}', -- JSON extensible metadata + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_messages_target_ts ON messages(target, ts); +CREATE INDEX IF NOT EXISTS idx_messages_from_user ON messages(from_user_id); + +-- Message queue: per-user pending delivery (unread messages) +CREATE TABLE IF NOT EXISTS message_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + queued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, message_id) +); +CREATE INDEX IF NOT EXISTS idx_message_queue_user_id ON message_queue(user_id, queued_at); + +-- Sessions: server-held session state +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, -- UUID + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_active_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME -- idle timeout +); +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); + +-- Server links: federation peer configuration +CREATE TABLE IF NOT EXISTS server_links ( + id TEXT PRIMARY KEY, -- UUID + name TEXT NOT NULL UNIQUE, -- human-readable peer name + url TEXT NOT NULL, -- base URL of peer server + shared_key_hash TEXT NOT NULL, -- hashed shared secret + is_active INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME ); diff --git a/internal/models/auth_token.go b/internal/models/auth_token.go new file mode 100644 index 0000000..5f6bc4e --- /dev/null +++ b/internal/models/auth_token.go @@ -0,0 +1,37 @@ +package models + +import ( + "context" + "time" +) + +// AuthToken represents an authentication token for a user session. +type AuthToken struct { + Base + + Token string `json:"-"` + UserID string `json:"userId"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` +} + +// User returns the user who owns this token. +func (t *AuthToken) User(ctx context.Context) (*User, error) { + u := &User{} + u.SetDB(t.db) + + err := t.GetDB().QueryRowContext(ctx, ` + SELECT id, nick, password_hash, created_at, updated_at, last_seen_at + FROM users WHERE id = ?`, + t.UserID, + ).Scan( + &u.ID, &u.Nick, &u.PasswordHash, + &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt, + ) + if err != nil { + return nil, err + } + + return u, nil +} diff --git a/internal/models/channel.go b/internal/models/channel.go index 2ad401e..a6ecea2 100644 --- a/internal/models/channel.go +++ b/internal/models/channel.go @@ -1,6 +1,7 @@ package models import ( + "context" "time" ) @@ -8,10 +9,88 @@ import ( type Channel struct { Base - ID int64 `json:"id"` + ID string `json:"id"` Name string `json:"name"` Topic string `json:"topic"` Modes string `json:"modes"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } + +// Members returns all users who are members of this channel. +func (c *Channel) Members(ctx context.Context) ([]*ChannelMember, error) { + rows, err := c.GetDB().QueryContext(ctx, ` + SELECT cm.channel_id, cm.user_id, cm.modes, cm.joined_at, + u.nick + FROM channel_members cm + JOIN users u ON u.id = cm.user_id + WHERE cm.channel_id = ? + ORDER BY cm.joined_at`, + c.ID, + ) + if err != nil { + return nil, err + } + + defer func() { _ = rows.Close() }() + + var members []*ChannelMember + + for rows.Next() { + m := &ChannelMember{} + m.SetDB(c.db) + + err = rows.Scan( + &m.ChannelID, &m.UserID, &m.Modes, + &m.JoinedAt, &m.Nick, + ) + if err != nil { + return nil, err + } + + members = append(members, m) + } + + return members, rows.Err() +} + +// RecentMessages returns the most recent messages in this channel. +func (c *Channel) RecentMessages( + ctx context.Context, + limit int, +) ([]*Message, error) { + rows, err := c.GetDB().QueryContext(ctx, ` + SELECT id, ts, from_user_id, from_nick, + target, type, body, meta, created_at + FROM messages + WHERE target = ? + ORDER BY ts DESC + LIMIT ?`, + c.Name, limit, + ) + if err != nil { + return nil, err + } + + defer func() { _ = rows.Close() }() + + var messages []*Message + + for rows.Next() { + msg := &Message{} + msg.SetDB(c.db) + + err = rows.Scan( + &msg.ID, &msg.Timestamp, &msg.FromUserID, + &msg.FromNick, &msg.Target, &msg.Type, + &msg.Body, &msg.Meta, &msg.CreatedAt, + ) + if err != nil { + return nil, err + } + + messages = append(messages, msg) + } + + return messages, rows.Err() +} diff --git a/internal/models/channel_member.go b/internal/models/channel_member.go new file mode 100644 index 0000000..c2d5e4d --- /dev/null +++ b/internal/models/channel_member.go @@ -0,0 +1,57 @@ +package models + +import ( + "context" + "time" +) + +// ChannelMember represents a user's membership in a channel. +type ChannelMember struct { + Base + + ChannelID string `json:"channelId"` + UserID string `json:"userId"` + Modes string `json:"modes"` + JoinedAt time.Time `json:"joinedAt"` + Nick string `json:"nick"` // denormalized from users table +} + +// User returns the full User for this membership. +func (cm *ChannelMember) User(ctx context.Context) (*User, error) { + u := &User{} + u.SetDB(cm.db) + + err := cm.GetDB().QueryRowContext(ctx, ` + SELECT id, nick, password_hash, created_at, updated_at, last_seen_at + FROM users WHERE id = ?`, + cm.UserID, + ).Scan( + &u.ID, &u.Nick, &u.PasswordHash, + &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt, + ) + if err != nil { + return nil, err + } + + return u, nil +} + +// Channel returns the full Channel for this membership. +func (cm *ChannelMember) Channel(ctx context.Context) (*Channel, error) { + c := &Channel{} + c.SetDB(cm.db) + + err := cm.GetDB().QueryRowContext(ctx, ` + SELECT id, name, topic, modes, created_at, updated_at + FROM channels WHERE id = ?`, + cm.ChannelID, + ).Scan( + &c.ID, &c.Name, &c.Topic, &c.Modes, + &c.CreatedAt, &c.UpdatedAt, + ) + if err != nil { + return nil, err + } + + return c, nil +} diff --git a/internal/models/message.go b/internal/models/message.go new file mode 100644 index 0000000..652ae0d --- /dev/null +++ b/internal/models/message.go @@ -0,0 +1,20 @@ +package models + +import ( + "time" +) + +// Message represents a chat message (channel or DM). +type Message struct { + Base + + ID string `json:"id"` + Timestamp time.Time `json:"ts"` + FromUserID string `json:"fromUserId"` + FromNick string `json:"from"` + Target string `json:"to"` + Type string `json:"type"` + Body string `json:"body"` + Meta string `json:"meta"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/internal/models/message_queue.go b/internal/models/message_queue.go new file mode 100644 index 0000000..616cbc3 --- /dev/null +++ b/internal/models/message_queue.go @@ -0,0 +1,15 @@ +package models + +import ( + "time" +) + +// MessageQueueEntry represents a pending message delivery for a user. +type MessageQueueEntry struct { + Base + + ID int64 `json:"id"` + UserID string `json:"userId"` + MessageID string `json:"messageId"` + QueuedAt time.Time `json:"queuedAt"` +} diff --git a/internal/models/model.go b/internal/models/model.go index 89be8d4..40ee96d 100644 --- a/internal/models/model.go +++ b/internal/models/model.go @@ -1,9 +1,11 @@ // Package models defines the data models used by the chat application. +// All model structs embed Base, which provides database access for +// relation-fetching methods directly on model instances. package models import "database/sql" -// DB is the interface that models use to query relations. +// DB is the interface that models use to query the database. // This avoids a circular import with the db package. type DB interface { GetDB() *sql.DB @@ -18,3 +20,8 @@ type Base struct { func (b *Base) SetDB(d DB) { b.db = d } + +// GetDB returns the database interface for use in model methods. +func (b *Base) GetDB() *sql.DB { + return b.db.GetDB() +} diff --git a/internal/models/server_link.go b/internal/models/server_link.go new file mode 100644 index 0000000..004ef67 --- /dev/null +++ b/internal/models/server_link.go @@ -0,0 +1,18 @@ +package models + +import ( + "time" +) + +// ServerLink represents a federation peer server configuration. +type ServerLink struct { + Base + + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + SharedKeyHash string `json:"-"` + IsActive bool `json:"isActive"` + CreatedAt time.Time `json:"createdAt"` + LastSeenAt *time.Time `json:"lastSeenAt,omitempty"` +} diff --git a/internal/models/session.go b/internal/models/session.go new file mode 100644 index 0000000..4b495f2 --- /dev/null +++ b/internal/models/session.go @@ -0,0 +1,37 @@ +package models + +import ( + "context" + "time" +) + +// Session represents a server-held user session. +type Session struct { + Base + + ID string `json:"id"` + UserID string `json:"userId"` + CreatedAt time.Time `json:"createdAt"` + LastActiveAt time.Time `json:"lastActiveAt"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` +} + +// User returns the user who owns this session. +func (s *Session) User(ctx context.Context) (*User, error) { + u := &User{} + u.SetDB(s.db) + + err := s.GetDB().QueryRowContext(ctx, ` + SELECT id, nick, password_hash, created_at, updated_at, last_seen_at + FROM users WHERE id = ?`, + s.UserID, + ).Scan( + &u.ID, &u.Nick, &u.PasswordHash, + &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt, + ) + if err != nil { + return nil, err + } + + return u, nil +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..214ea9b --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,92 @@ +package models + +import ( + "context" + "time" +) + +// User represents a registered user account. +type User struct { + Base + + ID string `json:"id"` + Nick string `json:"nick"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + LastSeenAt *time.Time `json:"lastSeenAt,omitempty"` +} + +// Channels returns all channels the user is a member of. +func (u *User) Channels(ctx context.Context) ([]*Channel, error) { + rows, err := u.GetDB().QueryContext(ctx, ` + SELECT c.id, c.name, c.topic, c.modes, c.created_at, c.updated_at + FROM channels c + JOIN channel_members cm ON cm.channel_id = c.id + WHERE cm.user_id = ? + ORDER BY c.name`, + u.ID, + ) + if err != nil { + return nil, err + } + + defer func() { _ = rows.Close() }() + + var channels []*Channel + + for rows.Next() { + c := &Channel{} + c.SetDB(u.db) + + err = rows.Scan( + &c.ID, &c.Name, &c.Topic, &c.Modes, + &c.CreatedAt, &c.UpdatedAt, + ) + if err != nil { + return nil, err + } + + channels = append(channels, c) + } + + return channels, rows.Err() +} + +// QueuedMessages returns undelivered messages for this user. +func (u *User) QueuedMessages(ctx context.Context) ([]*Message, error) { + rows, err := u.GetDB().QueryContext(ctx, ` + SELECT m.id, m.ts, m.from_user_id, m.from_nick, + m.target, m.type, m.body, m.meta, m.created_at + FROM messages m + JOIN message_queue mq ON mq.message_id = m.id + WHERE mq.user_id = ? + ORDER BY mq.queued_at ASC`, + u.ID, + ) + if err != nil { + return nil, err + } + + defer func() { _ = rows.Close() }() + + var messages []*Message + + for rows.Next() { + msg := &Message{} + msg.SetDB(u.db) + + err = rows.Scan( + &msg.ID, &msg.Timestamp, &msg.FromUserID, + &msg.FromNick, &msg.Target, &msg.Type, + &msg.Body, &msg.Meta, &msg.CreatedAt, + ) + if err != nil { + return nil, err + } + + messages = append(messages, msg) + } + + return messages, rows.Err() +}