package db import ( "context" "database/sql" "encoding/json" "time" ) type Space struct { ID string `json:"id"` UserID string `json:"userId"` Name string `json:"name"` Description string `json:"description"` Icon string `json:"icon"` Color string `json:"color"` CustomInstructions string `json:"customInstructions"` DefaultFocusMode string `json:"defaultFocusMode"` DefaultModel string `json:"defaultModel"` IsPublic bool `json:"isPublic"` Settings map[string]interface{} `json:"settings"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` ThreadCount int `json:"threadCount,omitempty"` Members []*SpaceMember `json:"members,omitempty"` MemberCount int `json:"memberCount,omitempty"` } type SpaceMember struct { ID string `json:"id"` SpaceID string `json:"spaceId"` UserID string `json:"userId"` Role string `json:"role"` Email string `json:"email,omitempty"` Name string `json:"name,omitempty"` Avatar string `json:"avatar,omitempty"` JoinedAt time.Time `json:"joinedAt"` } type SpaceInvite struct { ID string `json:"id"` SpaceID string `json:"spaceId"` Email string `json:"email"` Role string `json:"role"` InvitedBy string `json:"invitedBy"` Token string `json:"token"` ExpiresAt time.Time `json:"expiresAt"` CreatedAt time.Time `json:"createdAt"` } type SpaceRepository struct { db *PostgresDB } func NewSpaceRepository(db *PostgresDB) *SpaceRepository { return &SpaceRepository{db: db} } func (r *SpaceRepository) RunMigrations(ctx context.Context) error { migrations := []string{ `CREATE TABLE IF NOT EXISTS spaces ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, name VARCHAR(255) NOT NULL, description TEXT, icon VARCHAR(50), color VARCHAR(20), custom_instructions TEXT, default_focus_mode VARCHAR(50) DEFAULT 'all', default_model VARCHAR(100), is_public BOOLEAN DEFAULT FALSE, settings JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_spaces_user ON spaces(user_id)`, `CREATE TABLE IF NOT EXISTS space_members ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), space_id UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, user_id UUID NOT NULL, role VARCHAR(20) DEFAULT 'member', joined_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(space_id, user_id) )`, `CREATE INDEX IF NOT EXISTS idx_space_members_space ON space_members(space_id)`, `CREATE INDEX IF NOT EXISTS idx_space_members_user ON space_members(user_id)`, `CREATE TABLE IF NOT EXISTS space_invites ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), space_id UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, email VARCHAR(255) NOT NULL, role VARCHAR(20) DEFAULT 'member', invited_by UUID NOT NULL, token VARCHAR(64) NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_space_invites_token ON space_invites(token)`, `CREATE INDEX IF NOT EXISTS idx_space_invites_email ON space_invites(email)`, } for _, m := range migrations { if _, err := r.db.db.ExecContext(ctx, m); err != nil { return err } } return nil } func (r *SpaceRepository) Create(ctx context.Context, s *Space) error { settingsJSON, _ := json.Marshal(s.Settings) query := ` INSERT INTO spaces (user_id, name, description, icon, color, custom_instructions, default_focus_mode, default_model, is_public, settings) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at ` return r.db.db.QueryRowContext(ctx, query, s.UserID, s.Name, s.Description, s.Icon, s.Color, s.CustomInstructions, s.DefaultFocusMode, s.DefaultModel, s.IsPublic, settingsJSON, ).Scan(&s.ID, &s.CreatedAt, &s.UpdatedAt) } func (r *SpaceRepository) GetByID(ctx context.Context, id string) (*Space, error) { query := ` SELECT id, user_id, name, description, icon, color, custom_instructions, default_focus_mode, default_model, is_public, settings, created_at, updated_at, (SELECT COUNT(*) FROM threads WHERE space_id = spaces.id) as thread_count FROM spaces WHERE id = $1 ` var s Space var settingsJSON []byte err := r.db.db.QueryRowContext(ctx, query, id).Scan( &s.ID, &s.UserID, &s.Name, &s.Description, &s.Icon, &s.Color, &s.CustomInstructions, &s.DefaultFocusMode, &s.DefaultModel, &s.IsPublic, &settingsJSON, &s.CreatedAt, &s.UpdatedAt, &s.ThreadCount, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } json.Unmarshal(settingsJSON, &s.Settings) return &s, nil } func (r *SpaceRepository) GetByUserID(ctx context.Context, userID string) ([]*Space, error) { query := ` SELECT id, user_id, name, description, icon, color, custom_instructions, default_focus_mode, default_model, is_public, settings, created_at, updated_at, (SELECT COUNT(*) FROM threads WHERE space_id = spaces.id) as thread_count FROM spaces WHERE user_id = $1 ORDER BY updated_at DESC ` rows, err := r.db.db.QueryContext(ctx, query, userID) if err != nil { return nil, err } defer rows.Close() var spaces []*Space for rows.Next() { var s Space var settingsJSON []byte if err := rows.Scan( &s.ID, &s.UserID, &s.Name, &s.Description, &s.Icon, &s.Color, &s.CustomInstructions, &s.DefaultFocusMode, &s.DefaultModel, &s.IsPublic, &settingsJSON, &s.CreatedAt, &s.UpdatedAt, &s.ThreadCount, ); err != nil { return nil, err } json.Unmarshal(settingsJSON, &s.Settings) spaces = append(spaces, &s) } return spaces, nil } func (r *SpaceRepository) Update(ctx context.Context, s *Space, userID string) error { settingsJSON, _ := json.Marshal(s.Settings) query := ` UPDATE spaces SET name = $2, description = $3, icon = $4, color = $5, custom_instructions = $6, default_focus_mode = $7, default_model = $8, is_public = $9, settings = $10, updated_at = NOW() WHERE id = $1 AND user_id = $11 ` result, err := r.db.db.ExecContext(ctx, query, s.ID, s.Name, s.Description, s.Icon, s.Color, s.CustomInstructions, s.DefaultFocusMode, s.DefaultModel, s.IsPublic, settingsJSON, userID, ) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return ErrNotFound } return nil } func (r *SpaceRepository) Delete(ctx context.Context, id, userID string) error { result, err := r.db.db.ExecContext(ctx, "DELETE FROM spaces WHERE id = $1 AND user_id = $2", id, userID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return ErrNotFound } return nil } func (r *SpaceRepository) GetMembers(ctx context.Context, spaceID string) ([]*SpaceMember, error) { query := ` SELECT sm.id, sm.space_id, sm.user_id, sm.role, sm.joined_at, COALESCE(u.email, '') as email, COALESCE(u.name, '') as name, COALESCE(u.avatar, '') as avatar FROM space_members sm LEFT JOIN auth_users u ON sm.user_id = u.id WHERE sm.space_id = $1 ORDER BY sm.joined_at ASC ` rows, err := r.db.db.QueryContext(ctx, query, spaceID) if err != nil { return nil, err } defer rows.Close() var members []*SpaceMember for rows.Next() { var m SpaceMember if err := rows.Scan(&m.ID, &m.SpaceID, &m.UserID, &m.Role, &m.JoinedAt, &m.Email, &m.Name, &m.Avatar); err != nil { return nil, err } members = append(members, &m) } return members, nil } func (r *SpaceRepository) AddMember(ctx context.Context, spaceID, userID, role string) error { query := ` INSERT INTO space_members (space_id, user_id, role) VALUES ($1, $2, $3) ON CONFLICT (space_id, user_id) DO NOTHING ` _, err := r.db.db.ExecContext(ctx, query, spaceID, userID, role) return err } func (r *SpaceRepository) RemoveMember(ctx context.Context, spaceID, userID string) error { result, err := r.db.db.ExecContext(ctx, "DELETE FROM space_members WHERE space_id = $1 AND user_id = $2", spaceID, userID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return ErrNotFound } return nil } func (r *SpaceRepository) UpdateMemberRole(ctx context.Context, spaceID, userID, role string) error { result, err := r.db.db.ExecContext(ctx, "UPDATE space_members SET role = $3 WHERE space_id = $1 AND user_id = $2", spaceID, userID, role) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return ErrNotFound } return nil } func (r *SpaceRepository) IsMember(ctx context.Context, spaceID, userID string) (bool, string, error) { var role string err := r.db.db.QueryRowContext(ctx, "SELECT role FROM space_members WHERE space_id = $1 AND user_id = $2", spaceID, userID).Scan(&role) if err == sql.ErrNoRows { return false, "", nil } if err != nil { return false, "", err } return true, role, nil } func (r *SpaceRepository) GetByMemberID(ctx context.Context, userID string) ([]*Space, error) { query := ` SELECT DISTINCT s.id, s.user_id, s.name, s.description, s.icon, s.color, s.custom_instructions, s.default_focus_mode, s.default_model, s.is_public, s.settings, s.created_at, s.updated_at, (SELECT COUNT(*) FROM threads WHERE space_id = s.id) as thread_count, (SELECT COUNT(*) FROM space_members WHERE space_id = s.id) as member_count FROM spaces s LEFT JOIN space_members sm ON s.id = sm.space_id WHERE s.user_id = $1 OR sm.user_id = $1 ORDER BY s.updated_at DESC ` rows, err := r.db.db.QueryContext(ctx, query, userID) if err != nil { return nil, err } defer rows.Close() var spaces []*Space for rows.Next() { var s Space var settingsJSON []byte if err := rows.Scan( &s.ID, &s.UserID, &s.Name, &s.Description, &s.Icon, &s.Color, &s.CustomInstructions, &s.DefaultFocusMode, &s.DefaultModel, &s.IsPublic, &settingsJSON, &s.CreatedAt, &s.UpdatedAt, &s.ThreadCount, &s.MemberCount, ); err != nil { return nil, err } json.Unmarshal(settingsJSON, &s.Settings) spaces = append(spaces, &s) } return spaces, nil } func (r *SpaceRepository) CreateInvite(ctx context.Context, invite *SpaceInvite) error { query := ` INSERT INTO space_invites (space_id, email, role, invited_by, token, expires_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at ` return r.db.db.QueryRowContext(ctx, query, invite.SpaceID, invite.Email, invite.Role, invite.InvitedBy, invite.Token, invite.ExpiresAt, ).Scan(&invite.ID, &invite.CreatedAt) } func (r *SpaceRepository) GetInviteByToken(ctx context.Context, token string) (*SpaceInvite, error) { query := ` SELECT id, space_id, email, role, invited_by, token, expires_at, created_at FROM space_invites WHERE token = $1 AND expires_at > NOW() ` var inv SpaceInvite err := r.db.db.QueryRowContext(ctx, query, token).Scan( &inv.ID, &inv.SpaceID, &inv.Email, &inv.Role, &inv.InvitedBy, &inv.Token, &inv.ExpiresAt, &inv.CreatedAt, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &inv, nil } func (r *SpaceRepository) DeleteInvite(ctx context.Context, id string) error { _, err := r.db.db.ExecContext(ctx, "DELETE FROM space_invites WHERE id = $1", id) return err } func (r *SpaceRepository) GetInvitesBySpace(ctx context.Context, spaceID string) ([]*SpaceInvite, error) { query := ` SELECT id, space_id, email, role, invited_by, token, expires_at, created_at FROM space_invites WHERE space_id = $1 AND expires_at > NOW() ORDER BY created_at DESC ` rows, err := r.db.db.QueryContext(ctx, query, spaceID) if err != nil { return nil, err } defer rows.Close() var invites []*SpaceInvite for rows.Next() { var inv SpaceInvite if err := rows.Scan(&inv.ID, &inv.SpaceID, &inv.Email, &inv.Role, &inv.InvitedBy, &inv.Token, &inv.ExpiresAt, &inv.CreatedAt); err != nil { return nil, err } invites = append(invites, &inv) } return invites, nil }