package sqlite import ( "context" "database/sql" "encoding/json" "errors" "fmt" "time" "armature/models" "armature/store" ) // ── GroupStore ────────────────────────────── type GroupStore struct{} func NewGroupStore() *GroupStore { return &GroupStore{} } func (s *GroupStore) Create(ctx context.Context, g *models.Group) error { g.ID = store.NewID() now := time.Now().UTC() g.CreatedAt = now g.UpdatedAt = now if g.Source == "" { g.Source = "manual" } if g.Permissions == nil { g.Permissions = []string{} } permsJSON, err := json.Marshal(g.Permissions) if err != nil { return fmt.Errorf("marshal permissions: %w", err) } _, err = DB.ExecContext(ctx, ` INSERT INTO groups (id, name, description, scope, team_id, created_by, source, permissions, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, g.ID, g.Name, g.Description, g.Scope, models.NullString(g.TeamID), models.NullString(g.CreatedBy), g.Source, string(permsJSON), now.Format(timeFmt), now.Format(timeFmt), ) return err } func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) { var g models.Group var teamID sql.NullString var createdBy sql.NullString var permsStr sql.NullString err := DB.QueryRowContext(ctx, ` SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by, g.created_at, g.updated_at, (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, COALESCE(g.source, 'manual'), COALESCE(g.permissions, '[]') FROM groups g WHERE g.id = ?`, id).Scan( &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source, &permsStr, ) if err != nil { return nil, err } g.TeamID = NullableStringPtr(teamID) g.CreatedBy = NullableStringPtr(createdBy) g.Permissions = unmarshalStringSlice(permsStr.String) return &g, nil } // Update applies a patch to a group. // System groups allow permission edits but not structural field changes. func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error { var source string if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = ?", id).Scan(&source); err != nil { return err } b := NewUpdate("groups") if source != "system" { if patch.Name != nil { b.Set("name", *patch.Name) } if patch.Description != nil { b.Set("description", *patch.Description) } } if patch.Permissions != nil { permsJSON, err := json.Marshal(*patch.Permissions) if err != nil { return fmt.Errorf("marshal permissions: %w", err) } b.Set("permissions", string(permsJSON)) } if !b.HasSets() { return nil } b.Where("id", id) res, err := b.Exec(DB) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return sql.ErrNoRows } return nil } // Delete removes a group. Returns an error if source=system. func (s *GroupStore) Delete(ctx context.Context, id string) error { var source string if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = ?", id).Scan(&source); err != nil { if errors.Is(err, sql.ErrNoRows) { return sql.ErrNoRows } return err } if source == "system" { return store.ErrSystemGroup } res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = ?", id) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return sql.ErrNoRows } return nil } // ── Scoped Listing ────────────────────────── const groupSelectCols = ` g.id, g.name, g.description, g.scope, g.team_id, g.created_by, g.created_at, g.updated_at, (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, COALESCE(g.source, 'manual'), COALESCE(g.permissions, '[]')` func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) { return queryGroups(ctx, `SELECT `+groupSelectCols+` FROM groups g ORDER BY g.scope, g.name`) } func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) { return queryGroups(ctx, `SELECT `+groupSelectCols+` FROM groups g WHERE g.scope = 'team' AND g.team_id = ? ORDER BY g.name`, teamID) } func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) { return queryGroups(ctx, `SELECT `+groupSelectCols+` FROM groups g WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = ?) ORDER BY g.scope, g.name`, userID) } // ── Members ───────────────────────────────── func (s *GroupStore) AddMember(ctx context.Context, groupID, userID, addedBy string) error { _, err := DB.ExecContext(ctx, ` INSERT INTO group_members (id, group_id, user_id, added_by) VALUES (?, ?, ?, ?) ON CONFLICT (group_id, user_id) DO NOTHING`, store.NewID(), groupID, userID, addedBy) return err } func (s *GroupStore) RemoveMember(ctx context.Context, groupID, userID string) error { res, err := DB.ExecContext(ctx, "DELETE FROM group_members WHERE group_id = ? AND user_id = ?", groupID, userID) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return sql.ErrNoRows } return nil } func (s *GroupStore) ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error) { rows, err := DB.QueryContext(ctx, ` SELECT gm.id, gm.group_id, gm.user_id, gm.added_by, gm.added_at, u.username, u.email, COALESCE(u.display_name, '') FROM group_members gm JOIN users u ON u.id = gm.user_id WHERE gm.group_id = ? ORDER BY u.username`, groupID) if err != nil { return nil, err } defer rows.Close() var result []models.GroupMember for rows.Next() { var m models.GroupMember if err := rows.Scan(&m.ID, &m.GroupID, &m.UserID, &m.AddedBy, st(&m.AddedAt), &m.Username, &m.Email, &m.DisplayName); err != nil { return nil, err } result = append(result, m) } return result, rows.Err() } func (s *GroupStore) IsMember(ctx context.Context, groupID, userID string) (bool, error) { var exists bool err := DB.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ?)`, groupID, userID).Scan(&exists) return exists, err } func (s *GroupStore) GetUserGroupIDs(ctx context.Context, userID string) ([]string, error) { rows, err := DB.QueryContext(ctx, "SELECT group_id FROM group_members WHERE user_id = ?", userID) if err != nil { return nil, err } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } return ids, rows.Err() } // ── Scanners ──────────────────────────────── func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.Group, error) { rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("queryGroups: %w", err) } defer rows.Close() var result []models.Group for rows.Next() { var g models.Group var teamID sql.NullString var createdBy sql.NullString var permsStr sql.NullString if err := rows.Scan( &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source, &permsStr, ); err != nil { return nil, err } g.TeamID = NullableStringPtr(teamID) g.CreatedBy = NullableStringPtr(createdBy) g.Permissions = unmarshalStringSlice(permsStr.String) result = append(result, g) } return result, rows.Err() } func unmarshalStringSlice(s string) []string { if s == "" { return []string{} } var out []string if err := json.Unmarshal([]byte(s), &out); err != nil { return []string{} } return out }