added media fixes, search, push updates, pwa, metrics. preparing release 1.0
This commit is contained in:
@@ -181,4 +181,59 @@ func TestIntegration(t *testing.T) {
|
||||
if err != nil || !belongs {
|
||||
t.Fatalf("MessageBelongsToConversation = %v, %v; want true", belongs, err)
|
||||
}
|
||||
|
||||
// Search
|
||||
hits, err := db.SearchMessages(ctx, u1.ID, "hell", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchMessages: %v", err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].ID != msg.ID {
|
||||
t.Fatalf("SearchMessages = %+v, want single hit for message %d", hits, msg.ID)
|
||||
}
|
||||
if hits[0].ConversationType != "dm" {
|
||||
t.Fatalf("SearchMessages conversation type = %q, want dm", hits[0].ConversationType)
|
||||
}
|
||||
if hits, _ := db.SearchMessages(ctx, u1.ID, "", 10); hits != nil {
|
||||
t.Fatalf("SearchMessages empty query = %v, want nil", hits)
|
||||
}
|
||||
if hits, _ := db.SearchMessages(ctx, u1.ID, "zzz-not-found", 10); len(hits) != 0 {
|
||||
t.Fatalf("SearchMessages no-match = %d hits, want 0", len(hits))
|
||||
}
|
||||
|
||||
// Push subscriptions
|
||||
sub, err := db.CreatePushSubscription(ctx, PushSubscription{
|
||||
UserID: u1.ID,
|
||||
Endpoint: "https://push.example/abc",
|
||||
P256dh: "p256dh-key",
|
||||
Auth: "auth-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePushSubscription: %v", err)
|
||||
}
|
||||
sub2, err := db.CreatePushSubscription(ctx, PushSubscription{
|
||||
UserID: u1.ID,
|
||||
Endpoint: "https://push.example/abc",
|
||||
P256dh: "p256dh-key2",
|
||||
Auth: "auth-key2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePushSubscription upsert: %v", err)
|
||||
}
|
||||
if sub2.ID != sub.ID {
|
||||
t.Fatalf("upsert created a new row: %d vs %d", sub2.ID, sub.ID)
|
||||
}
|
||||
subs, err := db.ListPushSubscriptionsForUser(ctx, u1.ID)
|
||||
if err != nil || len(subs) != 1 {
|
||||
t.Fatalf("ListPushSubscriptionsForUser = %v, %v; want 1", subs, err)
|
||||
}
|
||||
if subs[0].P256dh != "p256dh-key2" {
|
||||
t.Fatalf("upsert did not update keys: %q", subs[0].P256dh)
|
||||
}
|
||||
if err := db.DeletePushSubscription(ctx, u1.ID, sub.Endpoint); err != nil {
|
||||
t.Fatalf("DeletePushSubscription: %v", err)
|
||||
}
|
||||
subs, _ = db.ListPushSubscriptionsForUser(ctx, u1.ID)
|
||||
if len(subs) != 0 {
|
||||
t.Fatalf("after delete = %d subs, want 0", len(subs))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 0005_push.sql
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
|
||||
@@ -0,0 +1,61 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PushSubscription is a browser Web Push endpoint registered by a user.
|
||||
type PushSubscription struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Endpoint string
|
||||
P256dh string
|
||||
Auth string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreatePushSubscription stores a subscription, replacing any previous one
|
||||
// bound to the same endpoint.
|
||||
func (s *Store) CreatePushSubscription(ctx context.Context, sub PushSubscription) (*PushSubscription, error) {
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO push_subscriptions(user_id, endpoint, p256dh, auth)
|
||||
VALUES($1, $2, $3, $4)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET user_id = EXCLUDED.user_id, p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth
|
||||
RETURNING id, created_at
|
||||
`, sub.UserID, sub.Endpoint, sub.P256dh, sub.Auth).Scan(&sub.ID, &sub.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sub, nil
|
||||
}
|
||||
|
||||
// DeletePushSubscription removes a subscription by endpoint and owner.
|
||||
func (s *Store) DeletePushSubscription(ctx context.Context, userID int64, endpoint string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM push_subscriptions WHERE user_id=$1 AND endpoint=$2
|
||||
`, userID, endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListPushSubscriptionsForUser returns all endpoints registered by a user.
|
||||
func (s *Store) ListPushSubscriptionsForUser(ctx context.Context, userID int64) ([]PushSubscription, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, user_id, endpoint, p256dh, auth, created_at
|
||||
FROM push_subscriptions WHERE user_id=$1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var subs []PushSubscription
|
||||
for rows.Next() {
|
||||
var sub PushSubscription
|
||||
if err := rows.Scan(&sub.ID, &sub.UserID, &sub.Endpoint, &sub.P256dh, &sub.Auth, &sub.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subs = append(subs, sub)
|
||||
}
|
||||
return subs, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// MessageHit is a search result: a message scoped to the member's
|
||||
// conversations, with its conversation context.
|
||||
type MessageHit struct {
|
||||
Message
|
||||
ConversationType string
|
||||
ConversationName string
|
||||
}
|
||||
|
||||
// SearchMessages finds messages matching query in conversations the user
|
||||
// belongs to, newest first. ILIKE substring matching keeps the search
|
||||
// dependency-free at family scale; pg_trgm is the documented scaling path.
|
||||
func (s *Store) SearchMessages(ctx context.Context, userID int64, query string, limit int) ([]MessageHit, error) {
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
pattern := "%" + query + "%"
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT m.id, m.conversation_id, m.sender_id, m.body, m.media_id, m.created_at, m.edited_at, m.deleted_at,
|
||||
c.type, c.name
|
||||
FROM messages m
|
||||
JOIN conversation_members cm ON cm.conversation_id = m.conversation_id AND cm.user_id = $1
|
||||
JOIN conversations c ON c.id = m.conversation_id
|
||||
WHERE m.body ILIKE $2
|
||||
ORDER BY m.id DESC
|
||||
LIMIT $3
|
||||
`, userID, pattern, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hits []MessageHit
|
||||
for rows.Next() {
|
||||
var h MessageHit
|
||||
if err := rows.Scan(
|
||||
&h.ID, &h.ConversationID, &h.SenderID, &h.Body, &h.MediaID, &h.CreatedAt, &h.EditedAt, &h.DeletedAt,
|
||||
&h.ConversationType, &h.ConversationName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hits = append(hits, h)
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
@@ -65,3 +65,16 @@ func (s *Store) RevokeAllSessions(ctx context.Context, userID int64) error {
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CleanupSessions removes expired sessions and revocations older than a week.
|
||||
func (s *Store) CleanupSessions(ctx context.Context) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM sessions
|
||||
WHERE expires_at < now()
|
||||
OR (revoked_at IS NOT NULL AND revoked_at < now() - interval '7 days')
|
||||
`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user