backend barebone done, big chunk of work done
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgconn"
|
||||
"github.com/jackc/pgerrcode"
|
||||
)
|
||||
|
||||
// AddContact creates a contact relation or returns ErrDuplicate if it exists.
|
||||
func (s *Store) AddContact(ctx context.Context, ownerID, contactID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO contacts(owner_id, contact_id)
|
||||
VALUES($1, $2)
|
||||
`, ownerID, contactID)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
|
||||
return ErrDuplicate
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveContact deletes a contact relation; no error if not present.
|
||||
func (s *Store) RemoveContact(ctx context.Context, ownerID, contactID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM contacts WHERE owner_id=$1 AND contact_id=$2
|
||||
`, ownerID, contactID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListContacts returns all contacts for an owner ordered by created_at descending.
|
||||
func (s *Store) ListContacts(ctx context.Context, ownerID int64) ([]Contact, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT owner_id, contact_id, created_at
|
||||
FROM contacts WHERE owner_id=$1
|
||||
ORDER BY created_at DESC
|
||||
`, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []Contact
|
||||
for rows.Next() {
|
||||
var c Contact
|
||||
if err := rows.Scan(&c.OwnerID, &c.ContactID, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, c)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// IsContact reports whether a contact relation exists.
|
||||
func (s *Store) IsContact(ctx context.Context, ownerID, contactID int64) (bool, error) {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM contacts WHERE owner_id=$1 AND contact_id=$2
|
||||
)
|
||||
`, ownerID, contactID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// CreateDM returns an existing direct-message conversation between two users or creates it.
|
||||
func (s *Store) CreateDM(ctx context.Context, userA, userB int64) (*Conversation, error) {
|
||||
// Check existing DM
|
||||
var conv Conversation
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT c.id, c.type, c.created_at
|
||||
FROM conversations c
|
||||
JOIN conversation_members m1 ON m1.conversation_id=c.id AND m1.user_id=$1
|
||||
JOIN conversation_members m2 ON m2.conversation_id=c.id AND m2.user_id=$2
|
||||
WHERE c.type='dm'
|
||||
LIMIT 1
|
||||
`, userA, userB).Scan(&conv.ID, &conv.Type, &conv.CreatedAt)
|
||||
if err == nil {
|
||||
return &conv, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, err := s.pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO conversations(type) VALUES('dm')
|
||||
RETURNING id, type, created_at
|
||||
`).Scan(&conv.ID, &conv.Type, &conv.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Add members
|
||||
members := []int64{userA, userB}
|
||||
for _, uid := range members {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO conversation_members(conversation_id, user_id)
|
||||
VALUES($1, $2)
|
||||
`, conv.ID, uid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
// GetConversationByID retrieves a conversation by ID or returns ErrNotFound.
|
||||
func (s *Store) GetConversationByID(ctx context.Context, id int64) (*Conversation, error) {
|
||||
var conv Conversation
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, type, created_at FROM conversations WHERE id=$1
|
||||
`, id).Scan(&conv.ID, &conv.Type, &conv.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
// IsMember reports whether a user is a member of a conversation.
|
||||
func (s *Store) IsMember(ctx context.Context, conversationID, userID int64) (bool, error) {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM conversation_members WHERE conversation_id=$1 AND user_id=$2
|
||||
)
|
||||
`, conversationID, userID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// ConversationIDsForUser returns all conversation IDs the user is a member of.
|
||||
func (s *Store) ConversationIDsForUser(ctx context.Context, userID int64) ([]int64, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT conversation_id FROM conversation_members WHERE user_id=$1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ListConversationsForUser returns summaries of conversations for a user.
|
||||
func (s *Store) ListConversationsForUser(ctx context.Context, userID int64) ([]ConversationSummary, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.id, c.type, c.created_at,
|
||||
m.id, m.conversation_id, m.sender_id, m.body, m.created_at, m.edited_at, m.deleted_at,
|
||||
uc.unread_count
|
||||
FROM conversation_members cm
|
||||
JOIN conversations c ON c.id = cm.conversation_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
||||
FROM messages WHERE conversation_id = cm.conversation_id
|
||||
ORDER BY id DESC LIMIT 1
|
||||
) m ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS unread_count
|
||||
FROM messages msg
|
||||
WHERE msg.conversation_id = cm.conversation_id
|
||||
AND msg.sender_id <> $1
|
||||
AND (cm.last_read_at IS NULL OR msg.created_at > cm.last_read_at)
|
||||
) uc ON true
|
||||
WHERE cm.user_id = $1
|
||||
ORDER BY m.id DESC NULLS LAST
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []ConversationSummary
|
||||
for rows.Next() {
|
||||
var cs ConversationSummary
|
||||
var msg Message
|
||||
if err := rows.Scan(
|
||||
&cs.ID, &cs.Type, &cs.CreatedAt,
|
||||
&msg.ID, &msg.ConversationID, &msg.SenderID, &msg.Body, &msg.CreatedAt, &msg.EditedAt, &msg.DeletedAt,
|
||||
&cs.UnreadCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.ID != 0 {
|
||||
cs.LastMessage = &msg
|
||||
}
|
||||
list = append(list, cs)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// UpdateLastRead sets the last_read_at to now for a user's conversation.
|
||||
func (s *Store) UpdateLastRead(ctx context.Context, conversationID, userID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE conversation_members SET last_read_at = now()
|
||||
WHERE conversation_id=$1 AND user_id=$2
|
||||
`, conversationID, userID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a record is not found in the store.
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
// ErrDuplicate is returned when attempting to create a record that already exists.
|
||||
var ErrDuplicate = errors.New("store: duplicate")
|
||||
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TestIntegration exercises basic CRUD operations against a real database.
|
||||
func TestIntegration(t *testing.T) {
|
||||
url := os.Getenv("MESSENGER_TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("skipping integration test; MESSENGER_TEST_DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to connect to db: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
db := New(pool)
|
||||
if err := db.Migrate(ctx); err != nil {
|
||||
t.Fatalf("Migrate failed: %v", err)
|
||||
}
|
||||
|
||||
// Users
|
||||
u1, err := db.CreateUser(ctx, "user1", "User One", "hash1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser u1: %v", err)
|
||||
}
|
||||
u2, err := db.CreateUser(ctx, "user2", "User Two", "hash2")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser u2: %v", err)
|
||||
}
|
||||
if _, err := db.GetUserByUsername(ctx, u1.Username); err != nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
if _, err := db.GetUserByID(ctx, u2.ID); err != nil {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
|
||||
// Sessions
|
||||
exp := time.Now().Add(time.Hour)
|
||||
sess, err := db.CreateSession(ctx, u1.ID, "rh1", "ua", "127.0.0.1", exp)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
if _, err := db.GetSessionByRefreshHash(ctx, sess.RefreshHash); err != nil {
|
||||
t.Fatalf("GetSessionByRefreshHash: %v", err)
|
||||
}
|
||||
if err := db.RevokeSession(ctx, sess.ID); err != nil {
|
||||
t.Fatalf("RevokeSession: %v", err)
|
||||
}
|
||||
if err := db.RevokeAllSessions(ctx, u1.ID); err != nil {
|
||||
t.Fatalf("RevokeAllSessions: %v", err)
|
||||
}
|
||||
|
||||
// Contacts
|
||||
if err := db.AddContact(ctx, u1.ID, u2.ID); err != nil {
|
||||
t.Fatalf("AddContact: %v", err)
|
||||
}
|
||||
if ok, err := db.IsContact(ctx, u1.ID, u2.ID); err != nil || !ok {
|
||||
t.Fatalf("IsContact: %v, %v", ok, err)
|
||||
}
|
||||
list, err := db.ListContacts(ctx, u1.ID)
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("ListContacts: got %v, err %v", list, err)
|
||||
}
|
||||
if err := db.RemoveContact(ctx, u1.ID, u2.ID); err != nil {
|
||||
t.Fatalf("RemoveContact: %v", err)
|
||||
}
|
||||
|
||||
// Conversations and messages
|
||||
conv, err := db.CreateDM(ctx, u1.ID, u2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDM: %v", err)
|
||||
}
|
||||
if conv2, err := db.CreateDM(ctx, u1.ID, u2.ID); err != nil || conv2.ID != conv.ID {
|
||||
t.Fatalf("CreateDM idempotent: %v, %v", err, conv2)
|
||||
}
|
||||
if ok, err := db.IsMember(ctx, conv.ID, u1.ID); err != nil || !ok {
|
||||
t.Fatalf("IsMember: %v, %v", ok, err)
|
||||
}
|
||||
ids, err := db.ConversationIDsForUser(ctx, u1.ID)
|
||||
if err != nil || len(ids) == 0 {
|
||||
t.Fatalf("ConversationIDsForUser: %v, %v", ids, err)
|
||||
}
|
||||
sums, err := db.ListConversationsForUser(ctx, u1.ID)
|
||||
if err != nil || len(sums) != 1 {
|
||||
t.Fatalf("ListConversationsForUser: %v, %v", sums, err)
|
||||
}
|
||||
|
||||
msg, err := db.InsertMessage(ctx, conv.ID, u1.ID, "hello")
|
||||
if err != nil {
|
||||
t.Fatalf("InsertMessage: %v", err)
|
||||
}
|
||||
if _, err := db.GetMessageByID(ctx, msg.ID); err != nil {
|
||||
t.Fatalf("GetMessageByID: %v", err)
|
||||
}
|
||||
msgs, err := db.ListMessages(ctx, conv.ID, 0, 10)
|
||||
if err != nil || len(msgs) != 1 {
|
||||
t.Fatalf("ListMessages: %v, %v", msgs, err)
|
||||
}
|
||||
if err := db.UpdateLastRead(ctx, conv.ID, u2.ID); err != nil {
|
||||
t.Fatalf("UpdateLastRead: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// InsertMessage creates a new message in a conversation and returns it.
|
||||
func (s *Store) InsertMessage(ctx context.Context, conversationID, senderID int64, body string) (*Message, error) {
|
||||
var m Message
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO messages(conversation_id, sender_id, body)
|
||||
VALUES($1, $2, $3)
|
||||
RETURNING id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
||||
`, conversationID, senderID, body).Scan(
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body,
|
||||
&m.CreatedAt, &m.EditedAt, &m.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// GetMessageByID retrieves a message by ID or returns ErrNotFound.
|
||||
func (s *Store) GetMessageByID(ctx context.Context, id int64) (*Message, error) {
|
||||
var m Message
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
||||
FROM messages WHERE id=$1
|
||||
`, id).Scan(
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body,
|
||||
&m.CreatedAt, &m.EditedAt, &m.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// ListMessages returns messages for a conversation in reverse chronological order.
|
||||
// If before > 0, only messages with ID less than before are returned.
|
||||
func (s *Store) ListMessages(ctx context.Context, conversationID int64, before int64, limit int) ([]Message, error) {
|
||||
var rows pgx.Rows
|
||||
var err error
|
||||
if before > 0 {
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
||||
FROM messages
|
||||
WHERE conversation_id=$1 AND id<$2
|
||||
ORDER BY id DESC LIMIT $3
|
||||
`, conversationID, before, limit)
|
||||
} else {
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
||||
FROM messages
|
||||
WHERE conversation_id=$1
|
||||
ORDER BY id DESC LIMIT $2
|
||||
`, conversationID, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var msgs []Message
|
||||
for rows.Next() {
|
||||
var m Message
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body,
|
||||
&m.CreatedAt, &m.EditedAt, &m.DeletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
// Migrate applies any pending SQL migrations in order within a single transaction.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
conn, err := s.pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if tx != nil {
|
||||
tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INT PRIMARY KEY
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := migrationFiles.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type mig struct {
|
||||
ver int
|
||||
name string
|
||||
}
|
||||
var migs []mig
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".sql") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(name, "_", 2)
|
||||
v, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
migs = append(migs, mig{ver: v, name: name})
|
||||
}
|
||||
sort.Slice(migs, func(i, j int) bool { return migs[i].ver < migs[j].ver })
|
||||
|
||||
for _, m := range migs {
|
||||
var exists bool
|
||||
err = tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)", m.ver).Scan(&exists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
data, err := migrationFiles.ReadFile("migrations/" + m.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, string(data)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, "INSERT INTO schema_migrations(version) VALUES($1)", m.ver); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
tx = nil
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
-- 0001_init.sql
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
avatar TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
refresh_hash TEXT NOT NULL,
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_refresh_hash ON sessions(refresh_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
owner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
contact_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(owner_id, contact_id),
|
||||
CHECK(owner_id <> contact_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('dm','group')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||
conversation_id BIGINT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
last_read_at TIMESTAMPTZ,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(conversation_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_members_user ON conversation_members(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
conversation_id BIGINT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
sender_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
edited_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id, id DESC);
|
||||
@@ -0,0 +1,67 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgconn"
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// CreateSession adds a new session record.
|
||||
func (s *Store) CreateSession(ctx context.Context, userID int64, refreshHash, userAgent, ip string, expiresAt time.Time) (*Session, error) {
|
||||
var sess Session
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO sessions(user_id, refresh_hash, user_agent, ip, expires_at)
|
||||
VALUES($1, $2, $3, $4, $5)
|
||||
RETURNING id, user_id, refresh_hash, expires_at, revoked_at, created_at, user_agent, ip
|
||||
`, userID, refreshHash, userAgent, ip, expiresAt).Scan(
|
||||
&sess.ID, &sess.UserID, &sess.RefreshHash, &sess.ExpiresAt,
|
||||
&sess.RevokedAt, &sess.CreatedAt, &sess.UserAgent, &sess.IP,
|
||||
)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
|
||||
return nil, ErrDuplicate
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// GetSessionByRefreshHash returns a session by its refresh hash or ErrNotFound.
|
||||
func (s *Store) GetSessionByRefreshHash(ctx context.Context, hash string) (*Session, error) {
|
||||
var sess Session
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, refresh_hash, expires_at, revoked_at, created_at, user_agent, ip
|
||||
FROM sessions WHERE refresh_hash=$1
|
||||
`, hash).Scan(
|
||||
&sess.ID, &sess.UserID, &sess.RefreshHash, &sess.ExpiresAt,
|
||||
&sess.RevokedAt, &sess.CreatedAt, &sess.UserAgent, &sess.IP,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// RevokeSession sets revoked_at to now() for the given session ID.
|
||||
func (s *Store) RevokeSession(ctx context.Context, id int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE sessions SET revoked_at = now() WHERE id = $1
|
||||
`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeAllSessions revokes all sessions for a given user.
|
||||
func (s *Store) RevokeAllSessions(ctx context.Context, userID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE sessions SET revoked_at = now() WHERE user_id = $1
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Store provides database access via a connection pool.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New creates a new Store with the given pgx pool.
|
||||
func New(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// User represents a user account.
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
DisplayName string
|
||||
PasswordHash string
|
||||
Avatar string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Session represents a user session for refresh tokens.
|
||||
type Session struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
RefreshHash string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
RevokedAt *time.Time
|
||||
UserAgent string
|
||||
IP string
|
||||
}
|
||||
|
||||
// Conversation represents a chat conversation.
|
||||
type Conversation struct {
|
||||
ID int64
|
||||
Type string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ConversationSummary provides a conversation with its last message and unread count.
|
||||
type ConversationSummary struct {
|
||||
Conversation
|
||||
LastMessage *Message
|
||||
UnreadCount int64
|
||||
}
|
||||
|
||||
// Message represents a message sent within a conversation.
|
||||
type Message struct {
|
||||
ID int64
|
||||
ConversationID int64
|
||||
SenderID int64
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
EditedAt *time.Time
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
// Contact represents a contact relationship between users.
|
||||
type Contact struct {
|
||||
OwnerID int64
|
||||
ContactID int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgconn"
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// CreateUser inserts a new user returning the created record or ErrDuplicate on conflict.
|
||||
func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordHash string) (*User, error) {
|
||||
var u User
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO users(username, display_name, password_hash)
|
||||
VALUES($1, $2, $3)
|
||||
RETURNING id, username, display_name, password_hash, avatar, created_at
|
||||
`, username, displayName, passwordHash).Scan(
|
||||
&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &u.Avatar, &u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
|
||||
return nil, ErrDuplicate
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetUserByUsername retrieves a user by username or returns ErrNotFound.
|
||||
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
var u User
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, username, display_name, password_hash, avatar, created_at
|
||||
FROM users WHERE username=$1
|
||||
`, username).Scan(
|
||||
&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &u.Avatar, &u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by ID or returns ErrNotFound.
|
||||
func (s *Store) GetUserByID(ctx context.Context, id int64) (*User, error) {
|
||||
var u User
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, username, display_name, password_hash, avatar, created_at
|
||||
FROM users WHERE id=$1
|
||||
`, id).Scan(
|
||||
&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &u.Avatar, &u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// SearchUsers returns users matching query in username or display_name via ILIKE.
|
||||
// Empty query returns an empty slice.
|
||||
func (s *Store) SearchUsers(ctx context.Context, query string, limit int) ([]User, error) {
|
||||
if query == "" {
|
||||
return []User{}, nil
|
||||
}
|
||||
pattern := "%" + query + "%"
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, username, display_name, password_hash, avatar, created_at
|
||||
FROM users
|
||||
WHERE username ILIKE $1 OR display_name ILIKE $1
|
||||
ORDER BY username
|
||||
LIMIT $2
|
||||
`, pattern, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(
|
||||
&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &u.Avatar, &u.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
Reference in New Issue
Block a user