Browse Source

switch channel based messaging to receiver list

david 4 days ago
parent
commit
b3a755eb56

+ 12 - 64
CLIENT_GUIDE.md

@@ -16,7 +16,7 @@ This document provides the necessary information to implement a client for the A
 
 ## Overview
 
-The ARP server exposes a **GraphQL API** for managing users, roles, permissions, services, tasks, notes, channels, and messages. All operations except `login` require authentication via a JWT bearer token.
+The ARP server exposes a **GraphQL API** for managing users, roles, permissions, services, tasks, notes, and messages. All operations except `login` require authentication via a JWT bearer token.
 
 ---
 
@@ -136,8 +136,6 @@ Many operations require specific permissions. The permission format is `resource
 | `deleteTask` | `task:delete` |
 | `updateTaskStatus` | `taskstatus:update` |
 | `deleteTaskStatus` | `taskstatus:delete` |
-| `updateChannel` | `channel:update` |
-| `deleteChannel` | `channel:delete` |
 | `updateMessage` | `message:update` |
 | `deleteMessage` | `message:delete` |
 
@@ -401,30 +399,6 @@ query TaskStatus($id: ID!) {
 }
 ```
 
-#### Channels
-
-```graphql
-# Get all channels
-query Channels {
-  channels {
-    id
-    participants { id email }
-    createdAt
-    updatedAt
-  }
-}
-
-# Get single channel by ID
-query Channel($id: ID!) {
-  channel(id: $id) {
-    id
-    participants { id email }
-    createdAt
-    updatedAt
-  }
-}
-```
-
 #### Messages
 
 ```graphql
@@ -432,11 +406,11 @@ query Channel($id: ID!) {
 query Messages {
   messages {
     id
-    conversationId
     senderId
     sender { id email }
     content
     sentAt
+    receivers
     createdAt
     updatedAt
   }
@@ -446,11 +420,11 @@ query Messages {
 query Message($id: ID!) {
   message(id: $id) {
     id
-    conversationId
     senderId
     sender { id email }
     content
     sentAt
+    receivers
     createdAt
     updatedAt
   }
@@ -697,56 +671,30 @@ mutation DeleteTaskStatus($id: ID!) {
 }
 ```
 
-#### Channels
-
-```graphql
-# Create channel
-mutation CreateChannel($input: NewChannel!) {
-  createChannel(input: $input) {
-    id
-    participants { id email }
-    createdAt
-  }
-}
-# Variables: { "input": { "participants": ["1", "2"] } }
-
-# Update channel (requires channel:update permission)
-mutation UpdateChannel($id: ID!, $input: UpdateChannelInput!) {
-  updateChannel(id: $id, input: $input) {
-    id
-    participants { id email }
-    updatedAt
-  }
-}
-
-# Delete channel (requires channel:delete permission)
-mutation DeleteChannel($id: ID!) {
-  deleteChannel(id: $id)
-}
-```
-
 #### Messages
 
 ```graphql
 # Create message
+# Note: sender is automatically set to the authenticated user
 mutation CreateMessage($input: NewMessage!) {
   createMessage(input: $input) {
     id
-    conversationId
     senderId
     sender { id email }
     content
     sentAt
+    receivers
     createdAt
   }
 }
-# Variables: { "input": { "conversationId": "1", "senderId": "1", "content": "Hello!" } }
+# Variables: { "input": { "content": "Hello!", "receivers": ["2", "3"] } }
 
 # Update message (requires message:update permission)
 mutation UpdateMessage($id: ID!, $input: UpdateMessageInput!) {
   updateMessage(id: $id, input: $input) {
     id
     content
+    receivers
     updatedAt
   }
 }
@@ -804,11 +752,11 @@ Subscriptions are **filtered by user context**. Users only receive events that a
 | `taskCreated` | Only if user is the **assignee** |
 | `taskUpdated` | Only if user is the **assignee** |
 | `taskDeleted` | Only if user is the **assignee** |
-| `messageAdded` | Only if user is a **participant** in the channel |
+| `messageAdded` | Only if user is a **receiver** of the message |
 
 This means:
 - A user will only receive task events for tasks assigned to them
-- A user will only receive message events for channels they are a participant in
+- A user will only receive message events for messages where they are a receiver
 - Unassigned tasks do not trigger notifications to any user
 
 ### Available Subscriptions
@@ -850,15 +798,15 @@ subscription TaskDeleted {
   }
 }
 
-# Message added - received by all channel participants
+# Message added - received by message receivers
 subscription MessageAdded {
   messageAdded {
     id
-    conversationId
     senderId
     sender { id email }
     content
     sentAt
+    receivers
   }
 }
 ```
@@ -998,7 +946,7 @@ for {
 | Task assignment notifications | `taskCreated` | User receives event when assigned a new task |
 | Task status updates | `taskUpdated` | User receives event when their assigned task is modified |
 | Task removal | `taskDeleted` | User receives event when their assigned task is deleted |
-| Chat messages | `messageAdded` | All channel participants receive new message events |
+| Direct messages | `messageAdded` | Message receivers receive new message events |
 
 ### Best Practices
 

+ 3 - 0
TODO.md

@@ -0,0 +1,3 @@
+* fix message notification -> sender still seems to be notified about their own messages.
+* maybe switch to direct messaging using a recipient id list.
+* add user profile description in model for users to figure out whom to include in messages

+ 0 - 374
arp_cli/cmd/channel.go

@@ -1,374 +0,0 @@
-package cmd
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"os"
-	"strings"
-
-	"gogs.dmsc.dev/arp/arp_cli/client"
-	"gogs.dmsc.dev/arp/arp_cli/config"
-
-	"github.com/AlecAivazis/survey/v2"
-	"github.com/olekukonko/tablewriter"
-	"github.com/urfave/cli/v3"
-)
-
-// ChannelCommand returns the channel command
-func ChannelCommand() *cli.Command {
-	return &cli.Command{
-		Name:  "channel",
-		Usage: "Manage channels",
-		Description: `Manage ARP channels. Channels are chat conversations between users/agents.
-
-Use this command to create, list, update, and delete channels.`,
-		Commands: []*cli.Command{
-			{
-				Name:    "list",
-				Aliases: []string{"ls"},
-				Usage:   "List all channels",
-				Flags: []cli.Flag{
-					&cli.BoolFlag{
-						Name:    "json",
-						Aliases: []string{"j"},
-						Usage:   "Output as JSON",
-					},
-				},
-				Action: channelList,
-			},
-			{
-				Name:  "get",
-				Usage: "Get a channel by ID",
-				Flags: []cli.Flag{
-					&cli.StringFlag{
-						Name:     "id",
-						Aliases:  []string{"i"},
-						Usage:    "Channel ID",
-						Required: true,
-					},
-					&cli.BoolFlag{
-						Name:    "json",
-						Aliases: []string{"j"},
-						Usage:   "Output as JSON",
-					},
-				},
-				Action: channelGet,
-			},
-			{
-				Name:   "create",
-				Usage:  "Create a new channel",
-				Action: channelCreate,
-				Flags: []cli.Flag{
-					&cli.StringSliceFlag{
-						Name:    "participants",
-						Aliases: []string{"p"},
-						Usage:   "Participant user IDs",
-					},
-				},
-			},
-			{
-				Name:   "update",
-				Usage:  "Update a channel",
-				Action: channelUpdate,
-				Flags: []cli.Flag{
-					&cli.StringFlag{
-						Name:     "id",
-						Aliases:  []string{"i"},
-						Usage:    "Channel ID",
-						Required: true,
-					},
-					&cli.StringSliceFlag{
-						Name:    "participants",
-						Aliases: []string{"p"},
-						Usage:   "Participant user IDs",
-					},
-				},
-			},
-			{
-				Name:   "delete",
-				Usage:  "Delete a channel",
-				Action: channelDelete,
-				Flags: []cli.Flag{
-					&cli.StringFlag{
-						Name:     "id",
-						Aliases:  []string{"i"},
-						Usage:    "Channel ID",
-						Required: true,
-					},
-					&cli.BoolFlag{
-						Name:    "yes",
-						Aliases: []string{"y"},
-						Usage:   "Skip confirmation",
-					},
-				},
-			},
-		},
-	}
-}
-
-type Channel struct {
-	ID           string `json:"id"`
-	Participants []struct {
-		ID    string `json:"id"`
-		Email string `json:"email"`
-	} `json:"participants"`
-	CreatedAt string `json:"createdAt"`
-	UpdatedAt string `json:"updatedAt"`
-}
-
-func channelList(ctx context.Context, cmd *cli.Command) error {
-	cfg, err := config.Load()
-	if err != nil {
-		return err
-	}
-	if err := RequireAuth(cfg); err != nil {
-		return err
-	}
-
-	c := client.New(cfg.ServerURL)
-	c.SetToken(cfg.Token)
-
-	query := "query Channels { channels { id participants { id email } createdAt updatedAt } }"
-
-	resp, err := c.Query(query, nil)
-	if err != nil {
-		return err
-	}
-
-	var result struct {
-		Channels []Channel `json:"channels"`
-	}
-	if err := json.Unmarshal(resp.Data, &result); err != nil {
-		return err
-	}
-
-	if cmd.Bool("json") {
-		enc := json.NewEncoder(os.Stdout)
-		enc.SetIndent("", "  ")
-		return enc.Encode(result.Channels)
-	}
-
-	if len(result.Channels) == 0 {
-		fmt.Println("No channels found.")
-		return nil
-	}
-
-	table := tablewriter.NewWriter(os.Stdout)
-	table.Header([]string{"ID", "Participants", "Created At"})
-	
-
-	for _, ch := range result.Channels {
-		participants := make([]string, len(ch.Participants))
-		for i, p := range ch.Participants {
-			participants[i] = p.Email
-		}
-		table.Append([]string{ch.ID, strings.Join(participants, ", "), ch.CreatedAt})
-	}
-
-	table.Render()
-	return nil
-}
-
-func channelGet(ctx context.Context, cmd *cli.Command) error {
-	cfg, err := config.Load()
-	if err != nil {
-		return err
-	}
-	if err := RequireAuth(cfg); err != nil {
-		return err
-	}
-
-	c := client.New(cfg.ServerURL)
-	c.SetToken(cfg.Token)
-
-	id := cmd.String("id")
-	query := "query Channel($id: ID!) { channel(id: $id) { id participants { id email } createdAt updatedAt } }"
-
-	resp, err := c.Query(query, map[string]interface{}{"id": id})
-	if err != nil {
-		return err
-	}
-
-	var result struct {
-		Channel *Channel `json:"channel"`
-	}
-	if err := json.Unmarshal(resp.Data, &result); err != nil {
-		return err
-	}
-
-	if result.Channel == nil {
-		return fmt.Errorf("channel not found")
-	}
-
-	if cmd.Bool("json") {
-		enc := json.NewEncoder(os.Stdout)
-		enc.SetIndent("", "  ")
-		return enc.Encode(result.Channel)
-	}
-
-	ch := result.Channel
-	fmt.Printf("ID: %s\n", ch.ID)
-	fmt.Printf("Participants:\n")
-	for _, p := range ch.Participants {
-		fmt.Printf("  - %s (%s)\n", p.Email, p.ID)
-	}
-	fmt.Printf("Created At: %s\n", ch.CreatedAt)
-	fmt.Printf("Updated At: %s\n", ch.UpdatedAt)
-
-	return nil
-}
-
-func channelCreate(ctx context.Context, cmd *cli.Command) error {
-	cfg, err := config.Load()
-	if err != nil {
-		return err
-	}
-	if err := RequireAuth(cfg); err != nil {
-		return err
-	}
-
-	participants := cmd.StringSlice("participants")
-
-	if len(participants) == 0 {
-		var participantsStr string
-		prompt := &survey.Input{Message: "Participant user IDs (comma-separated):"}
-		if err := survey.AskOne(prompt, &participantsStr, survey.WithValidator(survey.Required)); err != nil {
-			return err
-		}
-		for _, p := range strings.Split(participantsStr, ",") {
-			participants = append(participants, strings.TrimSpace(p))
-		}
-	}
-
-	c := client.New(cfg.ServerURL)
-	c.SetToken(cfg.Token)
-
-	mutation := `mutation CreateChannel($input: NewChannel!) { createChannel(input: $input) { id participants { id email } createdAt updatedAt } }`
-
-	input := map[string]interface{}{
-		"participants": participants,
-	}
-
-	resp, err := c.Mutation(mutation, map[string]interface{}{"input": input})
-	if err != nil {
-		return err
-	}
-
-	var result struct {
-		CreateChannel *Channel `json:"createChannel"`
-	}
-	if err := json.Unmarshal(resp.Data, &result); err != nil {
-		return err
-	}
-
-	if result.CreateChannel == nil {
-		return fmt.Errorf("failed to create channel")
-	}
-
-	fmt.Printf("Channel created successfully!\n")
-	fmt.Printf("ID: %s\n", result.CreateChannel.ID)
-
-	return nil
-}
-
-func channelUpdate(ctx context.Context, cmd *cli.Command) error {
-	cfg, err := config.Load()
-	if err != nil {
-		return err
-	}
-	if err := RequireAuth(cfg); err != nil {
-		return err
-	}
-
-	id := cmd.String("id")
-	participants := cmd.StringSlice("participants")
-
-	if len(participants) == 0 {
-		fmt.Println("No updates provided. Use flags to specify what to update.")
-		return nil
-	}
-
-	c := client.New(cfg.ServerURL)
-	c.SetToken(cfg.Token)
-
-	input := map[string]interface{}{
-		"participants": participants,
-	}
-
-	mutation := `mutation UpdateChannel($id: ID!, $input: UpdateChannelInput!) { updateChannel(id: $id, input: $input) { id participants { id email } createdAt updatedAt } }`
-
-	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id, "input": input})
-	if err != nil {
-		return err
-	}
-
-	var result struct {
-		UpdateChannel *Channel `json:"updateChannel"`
-	}
-	if err := json.Unmarshal(resp.Data, &result); err != nil {
-		return err
-	}
-
-	if result.UpdateChannel == nil {
-		return fmt.Errorf("channel not found")
-	}
-
-	fmt.Printf("Channel updated successfully!\n")
-	fmt.Printf("ID: %s\n", result.UpdateChannel.ID)
-
-	return nil
-}
-
-func channelDelete(ctx context.Context, cmd *cli.Command) error {
-	cfg, err := config.Load()
-	if err != nil {
-		return err
-	}
-	if err := RequireAuth(cfg); err != nil {
-		return err
-	}
-
-	id := cmd.String("id")
-	skipConfirm := cmd.Bool("yes")
-
-	if !skipConfirm {
-		confirm := false
-		prompt := &survey.Confirm{
-			Message: fmt.Sprintf("Are you sure you want to delete channel %s?", id),
-			Default: false,
-		}
-		if err := survey.AskOne(prompt, &confirm); err != nil {
-			return err
-		}
-		if !confirm {
-			fmt.Println("Deletion cancelled.")
-			return nil
-		}
-	}
-
-	c := client.New(cfg.ServerURL)
-	c.SetToken(cfg.Token)
-
-	mutation := `mutation DeleteChannel($id: ID!) { deleteChannel(id: $id) }`
-
-	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id})
-	if err != nil {
-		return err
-	}
-
-	var result struct {
-		DeleteChannel bool `json:"deleteChannel"`
-	}
-	if err := json.Unmarshal(resp.Data, &result); err != nil {
-		return err
-	}
-
-	if result.DeleteChannel {
-		fmt.Printf("Channel %s deleted successfully.\n", id)
-	} else {
-		fmt.Printf("Failed to delete channel %s.\n", id)
-	}
-
-	return nil
-}

+ 83 - 66
arp_cli/cmd/message.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"os"
+	"strings"
 
 	"gogs.dmsc.dev/arp/arp_cli/client"
 	"gogs.dmsc.dev/arp/arp_cli/config"
@@ -19,7 +20,7 @@ func MessageCommand() *cli.Command {
 	return &cli.Command{
 		Name:  "message",
 		Usage: "Manage messages",
-		Description: `Manage ARP messages. Messages are sent in channels between users/agents.
+		Description: `Manage ARP messages. Messages are sent between users/agents.
 
 Use this command to create, list, update, and delete messages. You can also watch for real-time message updates.`,
 		Commands: []*cli.Command{
@@ -59,15 +60,10 @@ Use this command to create, list, update, and delete messages. You can also watc
 				Usage:  "Create a new message",
 				Action: messageCreate,
 				Flags: []cli.Flag{
-					&cli.StringFlag{
-						Name:    "conversation",
-						Aliases: []string{"c"},
-						Usage:   "Conversation/Channel ID",
-					},
-					&cli.StringFlag{
-						Name:    "sender",
-						Aliases: []string{"s"},
-						Usage:   "Sender user ID",
+					&cli.StringSliceFlag{
+						Name:    "receivers",
+						Aliases: []string{"r"},
+						Usage:   "Receiver user IDs (comma-separated or multiple flags)",
 					},
 					&cli.StringFlag{
 						Name:    "content",
@@ -92,6 +88,11 @@ Use this command to create, list, update, and delete messages. You can also watc
 						Aliases: []string{"c"},
 						Usage:   "Message content",
 					},
+					&cli.StringSliceFlag{
+						Name:    "receivers",
+						Aliases: []string{"r"},
+						Usage:   "Receiver user IDs (comma-separated or multiple flags)",
+					},
 				},
 			},
 			{
@@ -122,14 +123,14 @@ Use this command to create, list, update, and delete messages. You can also watc
 }
 
 type Message struct {
-	ID             string `json:"id"`
-	ConversationID string `json:"conversationId"`
-	SenderID       string `json:"senderId"`
-	Sender         *User  `json:"sender"`
-	Content        string `json:"content"`
-	SentAt         string `json:"sentAt"`
-	CreatedAt      string `json:"createdAt"`
-	UpdatedAt      string `json:"updatedAt"`
+	ID        string   `json:"id"`
+	SenderID  string   `json:"senderId"`
+	Sender    *User    `json:"sender"`
+	Content   string   `json:"content"`
+	SentAt    string   `json:"sentAt"`
+	Receivers []string `json:"receivers"`
+	CreatedAt string   `json:"createdAt"`
+	UpdatedAt string   `json:"updatedAt"`
 }
 
 func messageList(ctx context.Context, cmd *cli.Command) error {
@@ -144,7 +145,7 @@ func messageList(ctx context.Context, cmd *cli.Command) error {
 	c := client.New(cfg.ServerURL)
 	c.SetToken(cfg.Token)
 
-	query := "query Messages { messages { id conversationId sender { id email } content sentAt createdAt } }"
+	query := "query Messages { messages { id sender { id email } content sentAt receivers createdAt } }"
 
 	resp, err := c.Query(query, nil)
 	if err != nil {
@@ -170,8 +171,7 @@ func messageList(ctx context.Context, cmd *cli.Command) error {
 	}
 
 	table := tablewriter.NewWriter(os.Stdout)
-	table.Header([]string{"ID", "Conversation", "Sender", "Content", "Sent At"})
-	
+	table.Header([]string{"ID", "Sender", "Content", "Receivers", "Sent At"})
 
 	for _, m := range result.Messages {
 		sender := ""
@@ -182,7 +182,8 @@ func messageList(ctx context.Context, cmd *cli.Command) error {
 		if len(content) > 50 {
 			content = content[:47] + "..."
 		}
-		table.Append([]string{m.ID, m.ConversationID, sender, content, m.SentAt})
+		receivers := strings.Join(m.Receivers, ", ")
+		table.Append([]string{m.ID, sender, content, receivers, m.SentAt})
 	}
 
 	table.Render()
@@ -202,7 +203,7 @@ func messageGet(ctx context.Context, cmd *cli.Command) error {
 	c.SetToken(cfg.Token)
 
 	id := cmd.String("id")
-	query := "query Message($id: ID!) { message(id: $id) { id conversationId sender { id email } content sentAt createdAt updatedAt } }"
+	query := "query Message($id: ID!) { message(id: $id) { id sender { id email } content sentAt receivers createdAt updatedAt } }"
 
 	resp, err := c.Query(query, map[string]interface{}{"id": id})
 	if err != nil {
@@ -228,13 +229,13 @@ func messageGet(ctx context.Context, cmd *cli.Command) error {
 
 	m := result.Message
 	fmt.Printf("ID: %s\n", m.ID)
-	fmt.Printf("Conversation ID: %s\n", m.ConversationID)
 	if m.Sender != nil {
 		fmt.Printf("Sender: %s (%s)\n", m.Sender.Email, m.SenderID)
 	} else {
 		fmt.Printf("Sender ID: %s\n", m.SenderID)
 	}
 	fmt.Printf("Content: %s\n", m.Content)
+	fmt.Printf("Receivers: %s\n", strings.Join(m.Receivers, ", "))
 	fmt.Printf("Sent At: %s\n", m.SentAt)
 	fmt.Printf("Created At: %s\n", m.CreatedAt)
 	fmt.Printf("Updated At: %s\n", m.UpdatedAt)
@@ -251,21 +252,18 @@ func messageCreate(ctx context.Context, cmd *cli.Command) error {
 		return err
 	}
 
-	conversationID := cmd.String("conversation")
-	senderID := cmd.String("sender")
+	receivers := cmd.StringSlice("receivers")
 	content := cmd.String("content")
 
-	if conversationID == "" {
-		prompt := &survey.Input{Message: "Conversation ID:"}
-		if err := survey.AskOne(prompt, &conversationID, survey.WithValidator(survey.Required)); err != nil {
+	if len(receivers) == 0 {
+		var receiverInput string
+		prompt := &survey.Input{Message: "Receiver user IDs (comma-separated):"}
+		if err := survey.AskOne(prompt, &receiverInput, survey.WithValidator(survey.Required)); err != nil {
 			return err
 		}
-	}
-
-	if senderID == "" {
-		prompt := &survey.Input{Message: "Sender user ID:"}
-		if err := survey.AskOne(prompt, &senderID, survey.WithValidator(survey.Required)); err != nil {
-			return err
+		receivers = strings.Split(receiverInput, ",")
+		for i, r := range receivers {
+			receivers[i] = strings.TrimSpace(r)
 		}
 	}
 
@@ -279,12 +277,11 @@ func messageCreate(ctx context.Context, cmd *cli.Command) error {
 	c := client.New(cfg.ServerURL)
 	c.SetToken(cfg.Token)
 
-	mutation := `mutation CreateMessage($input: NewMessage!) { createMessage(input: $input) { id conversationId sender { id email } content sentAt createdAt } }`
+	mutation := `mutation CreateMessage($input: NewMessage!) { createMessage(input: $input) { id sender { id email } content sentAt receivers createdAt } }`
 
 	input := map[string]interface{}{
-		"conversationId": conversationID,
-		"senderId":       senderID,
-		"content":        content,
+		"content":   content,
+		"receivers": receivers,
 	}
 
 	resp, err := c.Mutation(mutation, map[string]interface{}{"input": input})
@@ -320,10 +317,11 @@ func messageUpdate(ctx context.Context, cmd *cli.Command) error {
 
 	id := cmd.String("id")
 	content := cmd.String("content")
+	receivers := cmd.StringSlice("receivers")
 
-	if content == "" {
-		prompt := &survey.Multiline{Message: "New message content:"}
-		if err := survey.AskOne(prompt, &content, survey.WithValidator(survey.Required)); err != nil {
+	if content == "" && len(receivers) == 0 {
+		prompt := &survey.Multiline{Message: "Message content:"}
+		if err := survey.AskOne(prompt, &content); err != nil {
 			return err
 		}
 	}
@@ -331,11 +329,15 @@ func messageUpdate(ctx context.Context, cmd *cli.Command) error {
 	c := client.New(cfg.ServerURL)
 	c.SetToken(cfg.Token)
 
-	input := map[string]interface{}{
-		"content": content,
-	}
+	mutation := `mutation UpdateMessage($id: ID!, $input: UpdateMessage!) { updateMessage(id: $id, input: $input) { id sender { id email } content sentAt receivers createdAt updatedAt } }`
 
-	mutation := `mutation UpdateMessage($id: ID!, $input: UpdateMessageInput!) { updateMessage(id: $id, input: $input) { id content updatedAt } }`
+	input := map[string]interface{}{}
+	if content != "" {
+		input["content"] = content
+	}
+	if len(receivers) > 0 {
+		input["receivers"] = receivers
+	}
 
 	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id, "input": input})
 	if err != nil {
@@ -350,7 +352,7 @@ func messageUpdate(ctx context.Context, cmd *cli.Command) error {
 	}
 
 	if result.UpdateMessage == nil {
-		return fmt.Errorf("message not found")
+		return fmt.Errorf("failed to update message")
 	}
 
 	fmt.Printf("Message updated successfully!\n")
@@ -369,19 +371,15 @@ func messageDelete(ctx context.Context, cmd *cli.Command) error {
 	}
 
 	id := cmd.String("id")
-	skipConfirm := cmd.Bool("yes")
 
-	if !skipConfirm {
+	if !cmd.Bool("yes") {
 		confirm := false
-		prompt := &survey.Confirm{
-			Message: fmt.Sprintf("Are you sure you want to delete message %s?", id),
-			Default: false,
-		}
+		prompt := &survey.Confirm{Message: fmt.Sprintf("Are you sure you want to delete message %s?", id)}
 		if err := survey.AskOne(prompt, &confirm); err != nil {
 			return err
 		}
 		if !confirm {
-			fmt.Println("Deletion cancelled.")
+			fmt.Println("Cancelled.")
 			return nil
 		}
 	}
@@ -389,7 +387,7 @@ func messageDelete(ctx context.Context, cmd *cli.Command) error {
 	c := client.New(cfg.ServerURL)
 	c.SetToken(cfg.Token)
 
-	mutation := `mutation DeleteMessage($id: ID!) { deleteMessage(id: $id) }`
+	mutation := `mutation DeleteMessage($id: ID!) { deleteMessage(id: $id) { id } }`
 
 	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id})
 	if err != nil {
@@ -397,18 +395,18 @@ func messageDelete(ctx context.Context, cmd *cli.Command) error {
 	}
 
 	var result struct {
-		DeleteMessage bool `json:"deleteMessage"`
+		DeleteMessage *Message `json:"deleteMessage"`
 	}
 	if err := json.Unmarshal(resp.Data, &result); err != nil {
 		return err
 	}
 
-	if result.DeleteMessage {
-		fmt.Printf("Message %s deleted successfully.\n", id)
-	} else {
-		fmt.Printf("Failed to delete message %s.\n", id)
+	if result.DeleteMessage == nil {
+		return fmt.Errorf("failed to delete message")
 	}
 
+	fmt.Printf("Message %s deleted successfully!\n", result.DeleteMessage.ID)
+
 	return nil
 }
 
@@ -422,25 +420,44 @@ func messageWatch(ctx context.Context, cmd *cli.Command) error {
 	}
 
 	wsClient := client.NewWebSocketClient(cfg.ServerURL, cfg.Token)
-
 	if err := wsClient.Connect(); err != nil {
 		return fmt.Errorf("failed to connect: %w", err)
 	}
 	defer wsClient.Close()
 
-	fmt.Println("Watching for new messages...")
-	fmt.Println("Press Ctrl+C to stop.")
-
-	subscription := "subscription { messageAdded { id conversationId sender { id email } content sentAt } }"
+	subscription := `subscription MessageAdded { messageAdded { id sender { id email } content sentAt receivers createdAt } }`
 
 	if err := wsClient.Subscribe("1", subscription, nil); err != nil {
 		return fmt.Errorf("failed to subscribe: %w", err)
 	}
 
+	fmt.Println("Watching for new messages... (press Ctrl+C to stop)")
+
 	for {
 		select {
 		case msg := <-wsClient.Messages():
-			fmt.Printf("New message: %s\n", string(msg))
+			var result struct {
+				MessageAdded *Message `json:"messageAdded"`
+			}
+			if err := json.Unmarshal(msg, &result); err != nil {
+				fmt.Fprintf(os.Stderr, "Error parsing message: %v\n", err)
+				continue
+			}
+
+			if result.MessageAdded != nil {
+				m := result.MessageAdded
+				sender := "Unknown"
+				if m.Sender != nil {
+					sender = m.Sender.Email
+				}
+				fmt.Printf("\n--- New Message ---\n")
+				fmt.Printf("ID: %s\n", m.ID)
+				fmt.Printf("From: %s\n", sender)
+				fmt.Printf("Content: %s\n", m.Content)
+				fmt.Printf("Receivers: %s\n", strings.Join(m.Receivers, ", "))
+				fmt.Printf("Sent At: %s\n", m.SentAt)
+				fmt.Println("--------------------")
+			}
 		case err := <-wsClient.Errors():
 			fmt.Fprintf(os.Stderr, "Error: %v\n", err)
 		case <-wsClient.Done():

+ 1 - 2
arp_cli/cmd/root.go

@@ -66,7 +66,7 @@ func RootCommand() *cli.Command {
 		Usage: "Command-line interface for ARP (Agent-native ERP) server",
 		Description: `arp_cli is a command-line tool for interacting with the ARP GraphQL API.
 
-It provides CRUD operations for managing users, services, tasks, notes, channels,
+It provides CRUD operations for managing users, services, tasks, notes,
 messages, roles, and permissions. The CLI also supports real-time subscriptions
 for task and message events.
 
@@ -82,7 +82,6 @@ Start by running 'arp_cli login' to authenticate with your ARP server.`,
 			UserCommand(),
 			NoteCommand(),
 			TaskCommand(),
-			ChannelCommand(),
 			MessageCommand(),
 			RoleCommand(),
 			PermissionCommand(),

+ 1 - 2
arp_cli/main.go

@@ -31,7 +31,7 @@ func main() {
 		Usage: "Command-line interface for ARP (Agent-native ERP)",
 		Description: `ARP CLI is a command-line tool for interacting with ARP GraphQL servers.
 
-It provides CRUD operations for Services, Users, Notes, Tasks, Channels, Messages,
+It provides CRUD operations for Services, Users, Notes, Tasks, Messages,
 Roles, and Permissions. Real-time updates are available via WebSocket subscriptions
 for Tasks and Messages.
 
@@ -60,7 +60,6 @@ Then use the various commands to manage your ARP data.`,
 			cmd.UserCommand(),
 			cmd.NoteCommand(),
 			cmd.TaskCommand(),
-			cmd.ChannelCommand(),
 			cmd.MessageCommand(),
 			cmd.RoleCommand(),
 			cmd.PermissionCommand(),

+ 12 - 31
graph/converters.go

@@ -224,40 +224,21 @@ func convertTasks(tasks []models.Task) []*model.Task {
 	return result
 }
 
-// convertChannel converts models.Channel to model.Channel
-func convertChannel(c models.Channel) *model.Channel {
-	participants := make([]*model.User, len(c.Participants))
-	for i, u := range c.Participants {
-		participants[i] = convertUser(u)
-	}
-	return &model.Channel{
-		ID:           strconv.FormatUint(uint64(c.ID), 10),
-		Participants: participants,
-		CreatedAt:    c.CreatedAt.String(),
-		UpdatedAt:    c.UpdatedAt.String(),
-	}
-}
-
-// convertChannels converts []models.Channel to []*model.Channel
-func convertChannels(channels []models.Channel) []*model.Channel {
-	result := make([]*model.Channel, len(channels))
-	for i, c := range channels {
-		result[i] = convertChannel(c)
-	}
-	return result
-}
-
 // convertMessage converts models.Message to model.Message
 func convertMessage(m models.Message) *model.Message {
+	receivers := make([]string, len(m.Receivers))
+	for i, r := range m.Receivers {
+		receivers[i] = strconv.FormatUint(uint64(r.ID), 10)
+	}
 	return &model.Message{
-		ID:             strconv.FormatUint(uint64(m.ID), 10),
-		ConversationID: strconv.FormatUint(uint64(m.ConversationID), 10),
-		SenderID:       strconv.FormatUint(uint64(m.SenderID), 10),
-		Sender:         convertUser(m.Sender),
-		Content:        m.Content,
-		SentAt:         m.SentAt.String(),
-		CreatedAt:      m.CreatedAt.String(),
-		UpdatedAt:      m.UpdatedAt.String(),
+		ID:        strconv.FormatUint(uint64(m.ID), 10),
+		SenderID:  strconv.FormatUint(uint64(m.SenderID), 10),
+		Sender:    convertUser(m.Sender),
+		Content:   m.Content,
+		SentAt:    m.SentAt.String(),
+		Receivers: receivers,
+		CreatedAt: m.CreatedAt.String(),
+		UpdatedAt: m.UpdatedAt.String(),
 	}
 }
 

File diff suppressed because it is too large
+ 82 - 622
graph/generated.go


+ 15 - 68
graph/integration_test.go

@@ -32,7 +32,6 @@ type IDTracker struct {
 	Services     map[string]string
 	Tasks        map[string]string
 	Notes        map[string]string
-	Channels     []string
 	Messages     []string
 }
 
@@ -45,7 +44,6 @@ func NewIDTracker() *IDTracker {
 		Services:     make(map[string]string),
 		Tasks:        make(map[string]string),
 		Notes:        make(map[string]string),
-		Channels:     make([]string, 0),
 		Messages:     make([]string, 0),
 	}
 }
@@ -384,42 +382,19 @@ func TestIntegration_Bootstrap(t *testing.T) {
 		snapshotResult(t, "notes", string(jsonBytes))
 	})
 
-	// Phase 8: Create Channels
-	t.Run("CreateChannels", func(t *testing.T) {
-		for _, channel := range seed.Channels {
-			participantIDs := make([]string, len(channel.ParticipantEmails))
-			for i, email := range channel.ParticipantEmails {
-				participantIDs[i] = tracker.Users[email]
-			}
-			var response struct {
-				CreateChannel struct {
-					ID string `json:"id"`
-				} `json:"createChannel"`
-			}
-			query := fmt.Sprintf(`mutation { createChannel(input: {participants: ["%s"]}) { id } }`, strings.Join(participantIDs, `", "`))
-			err := tc.client.Post(query, &response)
-			if err != nil {
-				t.Fatalf("Failed to create channel: %v", err)
-			}
-			tracker.Channels = append(tracker.Channels, response.CreateChannel.ID)
-		}
-		var channelsResponse struct {
-			Channels []interface{} `json:"channels"`
-		}
-		tc.client.Post(`query { channels { id } }`, &channelsResponse)
-		jsonBytes, _ := json.MarshalIndent(channelsResponse, "", "  ")
-		snapshotResult(t, "channels", string(jsonBytes))
-	})
-
-	// Phase 9: Create Messages
+	// Phase 8: Create Messages
 	t.Run("CreateMessages", func(t *testing.T) {
 		for _, msg := range seed.Messages {
+			receiverIDs := make([]string, len(msg.ReceiverEmails))
+			for i, email := range msg.ReceiverEmails {
+				receiverIDs[i] = tracker.Users[email]
+			}
 			var response struct {
 				CreateMessage struct {
 					ID string `json:"id"`
 				} `json:"createMessage"`
 			}
-			query := fmt.Sprintf(`mutation { createMessage(input: {conversationId: "%s", senderId: "%s", content: "%s"}) { id } }`, tracker.Channels[msg.ChannelIndex], tracker.Users[msg.SenderEmail], msg.Content)
+			query := fmt.Sprintf(`mutation { createMessage(input: {content: "%s", receivers: ["%s"]}) { id } }`, msg.Content, strings.Join(receiverIDs, `", "`))
 			err := tc.client.Post(query, &response)
 			if err != nil {
 				t.Fatalf("Failed to create message: %v", err)
@@ -429,7 +404,7 @@ func TestIntegration_Bootstrap(t *testing.T) {
 		var messagesResponse struct {
 			Messages []interface{} `json:"messages"`
 		}
-		tc.client.Post(`query { messages { id content } }`, &messagesResponse)
+		tc.client.Post(`query { messages { id content receivers } }`, &messagesResponse)
 		jsonBytes, _ := json.MarshalIndent(messagesResponse, "", "  ")
 		snapshotResult(t, "messages", string(jsonBytes))
 	})
@@ -640,7 +615,7 @@ func TestIntegration_Subscriptions(t *testing.T) {
 	}()
 
 	// Subscribe to messageAdded as user1
-	messageAddedSub := `subscription { messageAdded { id content conversationId senderId } }`
+	messageAddedSub := `subscription { messageAdded { id content senderId receivers } }`
 	messageAddedChan := make(chan *model.Message, 1)
 	messageAddedSubscription := user1WSClient.Websocket(messageAddedSub)
 	defer messageAddedSubscription.Close()
@@ -665,26 +640,13 @@ func TestIntegration_Subscriptions(t *testing.T) {
 	// Give subscriptions time to connect
 	time.Sleep(100 * time.Millisecond)
 
-	// Create TestConversation with admin and user1 as participants
-	var createChannelResponse struct {
-		CreateChannel struct {
-			ID string `json:"id"`
-		} `json:"createChannel"`
-	}
-	createChannelQuery := fmt.Sprintf(`mutation { createChannel(input: {participants: ["%s", "%s"]}) { id } }`, adminID, user1ID)
-	err = adminClient.Post(createChannelQuery, &createChannelResponse)
-	if err != nil {
-		t.Fatalf("Failed to create channel: %v", err)
-	}
-	channelID := createChannelResponse.CreateChannel.ID
-
-	// Admin sends a message to the channel
+	// Admin sends a message to user1
 	var createMessageResponse struct {
 		CreateMessage struct {
 			ID string `json:"id"`
 		} `json:"createMessage"`
 	}
-	createMessageQuery := fmt.Sprintf(`mutation { createMessage(input: {conversationId: "%s", senderId: "%s", content: "Hello user1!"}) { id } }`, channelID, adminID)
+	createMessageQuery := fmt.Sprintf(`mutation { createMessage(input: {content: "Hello user1!", receivers: ["%s"]}) { id } }`, user1ID)
 	err = adminClient.Post(createMessageQuery, &createMessageResponse)
 	if err != nil {
 		t.Fatalf("Failed to create message: %v", err)
@@ -885,33 +847,18 @@ func bootstrapData(t *testing.T, tc *TestClient, tracker *IDTracker, seed testut
 		tracker.Notes[note.Title] = response.CreateNote.ID
 	}
 
-	// Create Channels
-	for _, channel := range seed.Channels {
-		participantIDs := make([]string, len(channel.ParticipantEmails))
-		for i, email := range channel.ParticipantEmails {
-			participantIDs[i] = tracker.Users[email]
-		}
-		var response struct {
-			CreateChannel struct {
-				ID string `json:"id"`
-			} `json:"createChannel"`
-		}
-		query := fmt.Sprintf(`mutation { createChannel(input: {participants: ["%s"]}) { id } }`, strings.Join(participantIDs, `", "`))
-		err := tc.client.Post(query, &response)
-		if err != nil {
-			t.Fatalf("Failed to create channel: %v", err)
-		}
-		tracker.Channels = append(tracker.Channels, response.CreateChannel.ID)
-	}
-
 	// Create Messages
 	for _, msg := range seed.Messages {
+		receiverIDs := make([]string, len(msg.ReceiverEmails))
+		for i, email := range msg.ReceiverEmails {
+			receiverIDs[i] = tracker.Users[email]
+		}
 		var response struct {
 			CreateMessage struct {
 				ID string `json:"id"`
 			} `json:"createMessage"`
 		}
-		query := fmt.Sprintf(`mutation { createMessage(input: {conversationId: "%s", senderId: "%s", content: "%s"}) { id } }`, tracker.Channels[msg.ChannelIndex], tracker.Users[msg.SenderEmail], msg.Content)
+		query := fmt.Sprintf(`mutation { createMessage(input: {content: "%s", receivers: ["%s"]}) { id } }`, msg.Content, strings.Join(receiverIDs, `", "`))
 		err := tc.client.Post(query, &response)
 		if err != nil {
 			t.Fatalf("Failed to create message: %v", err)

+ 12 - 29
graph/model/models_gen.go

@@ -7,35 +7,23 @@ type AuthPayload struct {
 	User  *User  `json:"user"`
 }
 
-type Channel struct {
-	ID           string  `json:"id"`
-	Participants []*User `json:"participants"`
-	CreatedAt    string  `json:"createdAt"`
-	UpdatedAt    string  `json:"updatedAt"`
-}
-
 type Message struct {
-	ID             string `json:"id"`
-	ConversationID string `json:"conversationId"`
-	SenderID       string `json:"senderId"`
-	Sender         *User  `json:"sender"`
-	Content        string `json:"content"`
-	SentAt         string `json:"sentAt"`
-	CreatedAt      string `json:"createdAt"`
-	UpdatedAt      string `json:"updatedAt"`
+	ID        string   `json:"id"`
+	SenderID  string   `json:"senderId"`
+	Sender    *User    `json:"sender"`
+	Content   string   `json:"content"`
+	SentAt    string   `json:"sentAt"`
+	Receivers []string `json:"receivers"`
+	CreatedAt string   `json:"createdAt"`
+	UpdatedAt string   `json:"updatedAt"`
 }
 
 type Mutation struct {
 }
 
-type NewChannel struct {
-	Participants []string `json:"participants"`
-}
-
 type NewMessage struct {
-	ConversationID string `json:"conversationId"`
-	SenderID       string `json:"senderId"`
-	Content        string `json:"content"`
+	Content   string   `json:"content"`
+	Receivers []string `json:"receivers"`
 }
 
 type NewNote struct {
@@ -154,14 +142,9 @@ type TaskStatus struct {
 	UpdatedAt string  `json:"updatedAt"`
 }
 
-type UpdateChannelInput struct {
-	Participants []string `json:"participants"`
-}
-
 type UpdateMessageInput struct {
-	ConversationID *string `json:"conversationId,omitempty"`
-	SenderID       *string `json:"senderId,omitempty"`
-	Content        *string `json:"content,omitempty"`
+	Content   *string  `json:"content,omitempty"`
+	Receivers []string `json:"receivers,omitempty"`
 }
 
 type UpdateNoteInput struct {

+ 7 - 9
graph/resolver.go

@@ -21,9 +21,8 @@ type TaskEvent struct {
 
 // MessageEvent represents a message event for subscriptions
 type MessageEvent struct {
-	Message        *model.Message
-	ChannelID      uint
-	ParticipantIDs []uint // IDs of users who should receive this event
+	Message     *model.Message
+	ReceiverIDs []uint // IDs of users who should receive this event
 }
 
 // Resolver is the main resolver struct
@@ -91,18 +90,17 @@ func (r *Resolver) SubscribeToMessages(userID uint) <-chan MessageEvent {
 	return ch
 }
 
-// PublishMessageEvent sends a message event to all channel participants
-func (r *Resolver) PublishMessageEvent(message *model.Message, channelID uint, participantIDs []uint) {
+// PublishMessageEvent sends a message event to all receivers
+func (r *Resolver) PublishMessageEvent(message *model.Message, receiverIDs []uint) {
 	event := MessageEvent{
-		Message:        message,
-		ChannelID:      channelID,
-		ParticipantIDs: participantIDs,
+		Message:     message,
+		ReceiverIDs: receiverIDs,
 	}
 
 	r.messageSubscribersMu.RLock()
 	defer r.messageSubscribersMu.RUnlock()
 
-	for _, userID := range participantIDs {
+	for _, userID := range receiverIDs {
 		if ch, ok := r.messageSubscribers[userID]; ok {
 			select {
 			case ch <- event:

+ 3 - 30
graph/schema.graphqls

@@ -87,22 +87,14 @@ type TaskStatus {
   updatedAt: String!
 }
 
-# Chat channel between users/agents
-type Channel {
-  id: ID!
-  participants: [User!]!
-  createdAt: String!
-  updatedAt: String!
-}
-
 # Message sent inside a conversation
 type Message {
   id: ID!
-  conversationId: ID!
   senderId: ID!
   sender: User!
   content: String!
   sentAt: String!
+  receivers: [ID!]!
   createdAt: String!
   updatedAt: String!
 }
@@ -137,10 +129,6 @@ type Query {
   taskStatuses: [TaskStatus!]!
   taskStatus(id: ID!): TaskStatus
 
-  # Channels
-  channels: [Channel!]!
-  channel(id: ID!): Channel
-
   # Messages
   messages: [Message!]!
   message(id: ID!): Message
@@ -186,11 +174,6 @@ type Mutation {
   updateTaskStatus(id: ID!, input: UpdateTaskStatusInput!): TaskStatus!
   deleteTaskStatus(id: ID!): Boolean!
 
-  # Channels
-  createChannel(input: NewChannel!): Channel!
-  updateChannel(id: ID!, input: UpdateChannelInput!): Channel!
-  deleteChannel(id: ID!): Boolean!
-
   # Messages
   createMessage(input: NewMessage!): Message!
   updateMessage(id: ID!, input: UpdateMessageInput!): Message!
@@ -290,24 +273,14 @@ input UpdateTaskStatusInput {
   label: String
 }
 
-input NewChannel {
-  participants: [ID!]!
-}
-
-input UpdateChannelInput {
-  participants: [ID!]!
-}
-
 input NewMessage {
-  conversationId: ID!
-  senderId: ID!
   content: String!
+  receivers: [ID!]!
 }
 
 input UpdateMessageInput {
-  conversationId: ID
-  senderId: ID
   content: String
+  receivers: [ID!]
 }
 
 # Subscriptions for real-time updates

+ 55 - 177
graph/schema.resolvers.go

@@ -820,124 +820,39 @@ func (r *mutationResolver) DeleteTaskStatus(ctx context.Context, id string) (boo
 	return result.RowsAffected > 0, nil
 }
 
-// CreateChannel is the resolver for the createChannel field.
-func (r *mutationResolver) CreateChannel(ctx context.Context, input model.NewChannel) (*model.Channel, error) {
-	// Auth check
-	if !auth.IsAuthenticated(ctx) {
-		return nil, errors.New("unauthorized: authentication required")
-	}
-
-	channel := models.Channel{}
-
-	for _, participantIDStr := range input.Participants {
-		participantID, err := toID(participantIDStr)
-		if err != nil {
-			return nil, fmt.Errorf("invalid participant ID: %w", err)
-		}
-		var user models.User
-		if err := r.DB.First(&user, participantID).Error; err != nil {
-			return nil, fmt.Errorf("participant not found: %w", err)
-		}
-		channel.Participants = append(channel.Participants, user)
-	}
-
-	if err := r.DB.Create(&channel).Error; err != nil {
-		return nil, fmt.Errorf("failed to create channel: %w", err)
-	}
-
-	// Reload with participants
-	r.DB.Preload("Participants").First(&channel, channel.ID)
-
-	logging.LogMutation(ctx, "CREATE", "CHANNEL", fmt.Sprintf("id=%d", channel.ID))
-	return convertChannel(channel), nil
-}
-
-// UpdateChannel is the resolver for the updateChannel field.
-func (r *mutationResolver) UpdateChannel(ctx context.Context, id string, input model.UpdateChannelInput) (*model.Channel, error) {
+// CreateMessage is the resolver for the createMessage field.
+func (r *mutationResolver) CreateMessage(ctx context.Context, input model.NewMessage) (*model.Message, error) {
 	// Auth check
 	if !auth.IsAuthenticated(ctx) {
 		return nil, errors.New("unauthorized: authentication required")
 	}
-	if !auth.HasPermission(ctx, "channel:update") {
-		return nil, errors.New("unauthorized: missing channel:update permission")
-	}
 
-	channelID, err := toID(id)
+	// Get sender from authenticated user
+	currentUser, err := auth.CurrentUser(ctx)
 	if err != nil {
-		return nil, fmt.Errorf("invalid channel ID: %w", err)
-	}
-
-	var existing models.Channel
-	if err := r.DB.Preload("Participants").First(&existing, channelID).Error; err != nil {
-		return nil, fmt.Errorf("channel not found: %w", err)
+		return nil, fmt.Errorf("failed to get current user: %w", err)
 	}
 
-	participants := []models.User{}
-	for _, participantIDStr := range input.Participants {
-		participantID, err := toID(participantIDStr)
+	// Build receivers list
+	receivers := make([]models.User, 0, len(input.Receivers))
+	receiverIDs := make([]uint, 0, len(input.Receivers))
+	for _, receiverIDStr := range input.Receivers {
+		receiverID, err := toID(receiverIDStr)
 		if err != nil {
-			return nil, fmt.Errorf("invalid participant ID: %w", err)
+			return nil, fmt.Errorf("invalid receiver ID: %w", err)
 		}
 		var user models.User
-		if err := r.DB.First(&user, participantID).Error; err != nil {
-			return nil, fmt.Errorf("participant not found: %w", err)
+		if err := r.DB.First(&user, receiverID).Error; err != nil {
+			return nil, fmt.Errorf("receiver not found: %w", err)
 		}
-		participants = append(participants, user)
-	}
-	existing.Participants = participants
-
-	if err := r.DB.Save(&existing).Error; err != nil {
-		return nil, fmt.Errorf("failed to update channel: %w", err)
-	}
-
-	logging.LogMutation(ctx, "UPDATE", "CHANNEL", id)
-	return convertChannel(existing), nil
-}
-
-// DeleteChannel is the resolver for the deleteChannel field.
-func (r *mutationResolver) DeleteChannel(ctx context.Context, id string) (bool, error) {
-	// Auth check
-	if !auth.IsAuthenticated(ctx) {
-		return false, errors.New("unauthorized: authentication required")
-	}
-	if !auth.HasPermission(ctx, "channel:delete") {
-		return false, errors.New("unauthorized: missing channel:delete permission")
-	}
-
-	channelID, err := toID(id)
-	if err != nil {
-		return false, fmt.Errorf("invalid channel ID: %w", err)
-	}
-
-	result := r.DB.Delete(&models.Channel{}, channelID)
-	if result.Error != nil {
-		return false, fmt.Errorf("failed to delete channel: %w", result.Error)
-	}
-
-	logging.LogMutation(ctx, "DELETE", "CHANNEL", id)
-	return result.RowsAffected > 0, nil
-}
-
-// CreateMessage is the resolver for the createMessage field.
-func (r *mutationResolver) CreateMessage(ctx context.Context, input model.NewMessage) (*model.Message, error) {
-	// Auth check
-	if !auth.IsAuthenticated(ctx) {
-		return nil, errors.New("unauthorized: authentication required")
-	}
-
-	conversationID, err := toID(input.ConversationID)
-	if err != nil {
-		return nil, fmt.Errorf("invalid conversation ID: %w", err)
-	}
-	senderID, err := toID(input.SenderID)
-	if err != nil {
-		return nil, fmt.Errorf("invalid sender ID: %w", err)
+		receivers = append(receivers, user)
+		receiverIDs = append(receiverIDs, receiverID)
 	}
 
 	message := models.Message{
-		ConversationID: conversationID,
-		SenderID:       senderID,
-		Content:        input.Content,
+		SenderID:  currentUser.ID,
+		Content:   input.Content,
+		Receivers: receivers,
 	}
 
 	if err := r.DB.Create(&message).Error; err != nil {
@@ -945,26 +860,21 @@ func (r *mutationResolver) CreateMessage(ctx context.Context, input model.NewMes
 	}
 
 	// Reload with associations
-	r.DB.Preload("Sender").First(&message, message.ID)
-
-	// Get channel participants for publishing the message event
-	var channel models.Channel
-	if err := r.DB.Preload("Participants").First(&channel, conversationID).Error; err == nil {
-		// Build list of participant IDs (excluding the sender to prevent notification loops)
-		participantIDs := make([]uint, 0, len(channel.Participants))
-		for _, participant := range channel.Participants {
-			if participant.ID != senderID {
-				participantIDs = append(participantIDs, participant.ID)
-			}
-		}
+	r.DB.Preload("Sender").Preload("Receivers").First(&message, message.ID)
 
-		// Publish message event to channel participants
-		graphqlMessage := convertMessage(message)
-		r.PublishMessageEvent(graphqlMessage, conversationID, participantIDs)
+	// Publish message event to receivers (excluding the sender to prevent notification loops)
+	notifyReceiverIDs := make([]uint, 0, len(receiverIDs))
+	for _, receiverID := range receiverIDs {
+		if receiverID != currentUser.ID {
+			notifyReceiverIDs = append(notifyReceiverIDs, receiverID)
+		}
 	}
 
+	graphqlMessage := convertMessage(message)
+	r.PublishMessageEvent(graphqlMessage, notifyReceiverIDs)
+
 	logging.LogMutation(ctx, "CREATE", "MESSAGE", fmt.Sprintf("id=%d", message.ID))
-	return convertMessage(message), nil
+	return graphqlMessage, nil
 }
 
 // UpdateMessage is the resolver for the updateMessage field.
@@ -983,32 +893,36 @@ func (r *mutationResolver) UpdateMessage(ctx context.Context, id string, input m
 	}
 
 	var existing models.Message
-	if err := r.DB.Preload("Sender").First(&existing, messageID).Error; err != nil {
+	if err := r.DB.Preload("Sender").Preload("Receivers").First(&existing, messageID).Error; err != nil {
 		return nil, fmt.Errorf("message not found: %w", err)
 	}
 
-	if input.ConversationID != nil {
-		conversationID, err := toID(*input.ConversationID)
-		if err != nil {
-			return nil, fmt.Errorf("invalid conversation ID: %w", err)
-		}
-		existing.ConversationID = conversationID
-	}
-	if input.SenderID != nil {
-		senderID, err := toID(*input.SenderID)
-		if err != nil {
-			return nil, fmt.Errorf("invalid sender ID: %w", err)
-		}
-		existing.SenderID = senderID
-	}
 	if input.Content != nil {
 		existing.Content = *input.Content
 	}
+	if len(input.Receivers) > 0 {
+		receivers := make([]models.User, 0, len(input.Receivers))
+		for _, receiverIDStr := range input.Receivers {
+			receiverID, err := toID(receiverIDStr)
+			if err != nil {
+				return nil, fmt.Errorf("invalid receiver ID: %w", err)
+			}
+			var user models.User
+			if err := r.DB.First(&user, receiverID).Error; err != nil {
+				return nil, fmt.Errorf("receiver not found: %w", err)
+			}
+			receivers = append(receivers, user)
+		}
+		existing.Receivers = receivers
+	}
 
 	if err := r.DB.Save(&existing).Error; err != nil {
 		return nil, fmt.Errorf("failed to update message: %w", err)
 	}
 
+	// Reload with associations
+	r.DB.Preload("Sender").Preload("Receivers").First(&existing, existing.ID)
+
 	logging.LogMutation(ctx, "UPDATE", "MESSAGE", id)
 	return convertMessage(existing), nil
 }
@@ -1289,42 +1203,6 @@ func (r *queryResolver) TaskStatus(ctx context.Context, id string) (*model.TaskS
 	return convertTaskStatus(status), nil
 }
 
-// Channels is the resolver for the channels field.
-func (r *queryResolver) Channels(ctx context.Context) ([]*model.Channel, error) {
-	// Auth check
-	if !auth.IsAuthenticated(ctx) {
-		return nil, errors.New("unauthorized: authentication required")
-	}
-
-	var channels []models.Channel
-	if err := r.DB.Preload("Participants").Find(&channels).Error; err != nil {
-		return nil, fmt.Errorf("failed to fetch channels: %w", err)
-	}
-	logging.LogQuery(ctx, "CHANNELS", "all")
-	return convertChannels(channels), nil
-}
-
-// Channel is the resolver for the channel field.
-func (r *queryResolver) Channel(ctx context.Context, id string) (*model.Channel, error) {
-	// Auth check
-	if !auth.IsAuthenticated(ctx) {
-		return nil, errors.New("unauthorized: authentication required")
-	}
-
-	channelID, err := toID(id)
-	if err != nil {
-		return nil, fmt.Errorf("invalid channel ID: %w", err)
-	}
-
-	var channel models.Channel
-	if err := r.DB.Preload("Participants").First(&channel, channelID).Error; err != nil {
-		return nil, fmt.Errorf("channel not found: %w", err)
-	}
-
-	logging.LogQuery(ctx, "CHANNEL", id)
-	return convertChannel(channel), nil
-}
-
 // Messages is the resolver for the messages field.
 func (r *queryResolver) Messages(ctx context.Context) ([]*model.Message, error) {
 	// Auth check
@@ -1488,7 +1366,7 @@ func (r *subscriptionResolver) TaskDeleted(ctx context.Context) (<-chan *model.T
 }
 
 // MessageAdded is the resolver for the messageAdded field.
-// Users only receive events for messages in channels where they are participants.
+// Users only receive events for messages where they are in the receivers list.
 func (r *subscriptionResolver) MessageAdded(ctx context.Context) (<-chan *model.Message, error) {
 	// Get current user
 	user, err := auth.CurrentUser(ctx)
@@ -1514,15 +1392,15 @@ func (r *subscriptionResolver) MessageAdded(ctx context.Context) (<-chan *model.
 				if !ok {
 					return
 				}
-				// Check if user is in the participant list
-				isParticipant := false
-				for _, participantID := range event.ParticipantIDs {
-					if participantID == user.ID {
-						isParticipant = true
+				// Check if user is in the receiver list
+				isReceiver := false
+				for _, receiverID := range event.ReceiverIDs {
+					if receiverID == user.ID {
+						isReceiver = true
 						break
 					}
 				}
-				if isParticipant && event.Message != nil {
+				if isReceiver && event.Message != nil {
 					select {
 					case outputChan <- event.Message:
 					default:

+ 10 - 5
graph/testdata/snapshots/TestIntegration_Bootstrap-CreateMessages

@@ -2,19 +2,24 @@ messages
 {
   "messages": [
     {
-      "content": "Welcome to the project channel!"
+      "content": "Welcome to the project channel!",
+      "receivers": []
     },
     {
-      "content": "Thanks! Excited to get started."
+      "content": "Thanks! Excited to get started.",
+      "receivers": []
     },
     {
-      "content": "Hey, let us discuss the API documentation."
+      "content": "Hey, let us discuss the API documentation.",
+      "receivers": []
     },
     {
-      "content": "Sure, I will start drafting it today."
+      "content": "Sure, I will start drafting it today.",
+      "receivers": []
     },
     {
-      "content": "Team announcement: Sprint review tomorrow at 2pm."
+      "content": "Team announcement: Sprint review tomorrow at 2pm.",
+      "receivers": []
     }
   ]
 }

+ 18 - 36
graph/testutil/fixtures.go

@@ -26,7 +26,6 @@ func SetupTestDB() (*gorm.DB, error) {
 		&models.Service{},
 		&models.Task{},
 		&models.TaskStatus{},
-		&models.Channel{},
 		&models.Message{},
 		&models.Note{},
 	)
@@ -72,7 +71,6 @@ type SeedData struct {
 	Services     []ServiceFixture
 	Tasks        []TaskFixture
 	Notes        []NoteFixture
-	Channels     []ChannelFixture
 	Messages     []MessageFixture
 }
 
@@ -128,16 +126,11 @@ type NoteFixture struct {
 	ServiceName string
 }
 
-// ChannelFixture represents test channel data
-type ChannelFixture struct {
-	ParticipantEmails []string
-}
-
 // MessageFixture represents test message data
 type MessageFixture struct {
-	ChannelIndex int
-	SenderEmail  string
-	Content      string
+	SenderEmail    string
+	Content        string
+	ReceiverEmails []string
 }
 
 // GetSeedData returns the hardcoded seed data for testing
@@ -270,42 +263,31 @@ func GetSeedData() SeedData {
 				ServiceName: "Project Beta",
 			},
 		},
-		Channels: []ChannelFixture{
-			{
-				ParticipantEmails: []string{"admin@example.com", "member1@example.com"},
-			},
-			{
-				ParticipantEmails: []string{"member1@example.com", "member2@example.com"},
-			},
-			{
-				ParticipantEmails: []string{"admin@example.com", "member1@example.com", "member2@example.com", "viewer@example.com"},
-			},
-		},
 		Messages: []MessageFixture{
 			{
-				ChannelIndex: 0,
-				SenderEmail:  "admin@example.com",
-				Content:      "Welcome to the project channel!",
+				SenderEmail:    "admin@example.com",
+				Content:        "Welcome to the project channel!",
+				ReceiverEmails: []string{"member1@example.com"},
 			},
 			{
-				ChannelIndex: 0,
-				SenderEmail:  "member1@example.com",
-				Content:      "Thanks! Excited to get started.",
+				SenderEmail:    "member1@example.com",
+				Content:        "Thanks! Excited to get started.",
+				ReceiverEmails: []string{"admin@example.com"},
 			},
 			{
-				ChannelIndex: 1,
-				SenderEmail:  "member1@example.com",
-				Content:      "Hey, let us discuss the API documentation.",
+				SenderEmail:    "member1@example.com",
+				Content:        "Hey, let us discuss the API documentation.",
+				ReceiverEmails: []string{"member2@example.com"},
 			},
 			{
-				ChannelIndex: 1,
-				SenderEmail:  "member2@example.com",
-				Content:      "Sure, I will start drafting it today.",
+				SenderEmail:    "member2@example.com",
+				Content:        "Sure, I will start drafting it today.",
+				ReceiverEmails: []string{"member1@example.com"},
 			},
 			{
-				ChannelIndex: 2,
-				SenderEmail:  "admin@example.com",
-				Content:      "Team announcement: Sprint review tomorrow at 2pm.",
+				SenderEmail:    "admin@example.com",
+				Content:        "Team announcement: Sprint review tomorrow at 2pm.",
+				ReceiverEmails: []string{"member1@example.com", "member2@example.com", "viewer@example.com"},
 			},
 		},
 	}

+ 8 - 19
models/models.go

@@ -111,27 +111,16 @@ type TaskStatus struct {
 	UpdatedAt time.Time
 }
 
-// Simple chat / messaging between users/agents
-type Channel struct {
-	ID    uint   `gorm:"primaryKey"`
-	Title string `gorm:"size:200;not null"`
-
-	// Participants – many‑to‑many (GORM will create the join table automatically)
-	Participants []User `gorm:"many2many:conversation_participants;"`
-
-	CreatedAt time.Time
-	UpdatedAt time.Time
-}
-
 // Message sent inside a conversation.
 type Message struct {
-	ID             uint `gorm:"primaryKey"`
-	ConversationID uint `gorm:"index;not null"` // refers to Channel.ID
-	SenderID       uint `gorm:"index;not null"` // user/agent that authored the message
-	Sender         User `gorm:"foreignKey:SenderID"`
-
-	Content string    `gorm:"type:text;not null"`
-	SentAt  time.Time `gorm:"autoCreateTime"`
+	ID       uint      `gorm:"primaryKey"`
+	SenderID uint      `gorm:"index;not null"` // user/agent that authored the message
+	Sender   User      `gorm:"foreignKey:SenderID"`
+	Content  string    `gorm:"type:text;not null"`
+	SentAt   time.Time `gorm:"autoCreateTime"`
+
+	// Receivers – many‑to‑many (users who should receive this message)
+	Receivers []User `gorm:"many2many:message_receivers;"`
 
 	CreatedAt time.Time
 	UpdatedAt time.Time

+ 1 - 1
server.go

@@ -34,7 +34,7 @@ func main() {
 	}
 
 	// Run auto-migration for all models
-	err = db.AutoMigrate(&models.User{}, &models.Role{}, &models.Permission{}, &models.Service{}, &models.Task{}, &models.TaskStatus{}, &models.Channel{}, &models.Message{}, &models.Note{})
+	err = db.AutoMigrate(&models.User{}, &models.Role{}, &models.Permission{}, &models.Service{}, &models.Task{}, &models.TaskStatus{}, &models.Message{}, &models.Note{})
 	if err != nil {
 		log.Fatal("failed to migrate database:", err)
 	}

Some files were not shown because too many files changed in this diff