All checks were successful
CI/CD / detect-changes (pull_request) Successful in 21s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-sqlite (pull_request) Successful in 2m52s
CI/CD / test-go-pg (pull_request) Successful in 3m6s
CI/CD / build-and-deploy (pull_request) Successful in 1m41s
Packages can declare `adoptable: true` in their manifest. When a team adopts an adoptable package, a team-scoped copy is created that references the original via `adopted_from` (shared assets, no disk duplication). The package's `requires_roles` auto-populate into a new `team_role_catalog` table so team admins know which roles to assign. Migration 017: adoptable/adopted_from columns on packages, team_role_catalog table. 4 new endpoints (adopt, list adoptable, unadopt, role catalog). AdoptTeamWorkflow deprecated. 11 new tests, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
441 lines
13 KiB
Go
441 lines
13 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"armature/models"
|
|
"armature/store"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type TeamStore struct{}
|
|
|
|
func NewTeamStore() *TeamStore { return &TeamStore{} }
|
|
|
|
func (s *TeamStore) Create(ctx context.Context, t *models.Team) error {
|
|
settingsJSON := ToJSON(t.Settings)
|
|
t.ID = store.NewID()
|
|
now := time.Now().UTC()
|
|
t.CreatedAt = now
|
|
t.UpdatedAt = now
|
|
_, err := DB.ExecContext(ctx, `
|
|
INSERT INTO teams (id, name, description, created_by, is_active, settings, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
t.ID, t.Name, t.Description, t.CreatedBy, t.IsActive, settingsJSON,
|
|
now.Format(timeFmt), now.Format(timeFmt),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *TeamStore) GetByID(ctx context.Context, id string) (*models.Team, error) {
|
|
var t models.Team
|
|
var settingsJSON []byte
|
|
err := DB.QueryRowContext(ctx, `
|
|
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
|
|
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
|
|
FROM teams WHERE id = ?`, id).Scan(
|
|
&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
|
|
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MemberCount,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
json.Unmarshal(settingsJSON, &t.Settings)
|
|
return &t, nil
|
|
}
|
|
|
|
func (s *TeamStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
|
|
b := NewUpdate("teams")
|
|
for k, v := range fields {
|
|
if k == "settings" {
|
|
b.SetJSON(k, v)
|
|
} else {
|
|
b.Set(k, v)
|
|
}
|
|
}
|
|
if !b.HasSets() {
|
|
return nil
|
|
}
|
|
b.Where("id", id)
|
|
_, err := b.Exec(DB)
|
|
return err
|
|
}
|
|
|
|
func (s *TeamStore) Delete(ctx context.Context, id string) error {
|
|
_, err := DB.ExecContext(ctx, "DELETE FROM teams WHERE id = ?", id)
|
|
return err
|
|
}
|
|
|
|
func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
|
|
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
|
|
FROM teams ORDER BY name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []models.Team
|
|
for rows.Next() {
|
|
var t models.Team
|
|
var settingsJSON []byte
|
|
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
|
|
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MemberCount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
json.Unmarshal(settingsJSON, &t.Settings)
|
|
result = append(result, t)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *TeamStore) ListForUser(ctx context.Context, userID string) ([]models.Team, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT t.id, t.name, t.description, t.created_by, t.is_active,
|
|
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
|
|
tm.role AS my_role,
|
|
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count
|
|
FROM teams t
|
|
JOIN team_members tm ON tm.team_id = t.id AND tm.user_id = ?
|
|
WHERE t.is_active = 1
|
|
ORDER BY t.name ASC`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []models.Team
|
|
for rows.Next() {
|
|
var t models.Team
|
|
var settingsJSON []byte
|
|
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
|
|
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MyRole, &t.MemberCount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
json.Unmarshal(settingsJSON, &t.Settings)
|
|
result = append(result, t)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// ── Members ─────────────────────────────────
|
|
|
|
func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error {
|
|
_, err := DB.ExecContext(ctx, `
|
|
INSERT INTO team_members (id, team_id, user_id, role) VALUES (?, ?, ?, ?)
|
|
ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role`,
|
|
store.NewID(), teamID, userID, role)
|
|
return err
|
|
}
|
|
|
|
func (s *TeamStore) RemoveMember(ctx context.Context, teamID, userID string) error {
|
|
_, err := DB.ExecContext(ctx, "DELETE FROM team_members WHERE team_id = ? AND user_id = ?",
|
|
teamID, userID)
|
|
return err
|
|
}
|
|
|
|
func (s *TeamStore) UpdateMemberRole(ctx context.Context, teamID, userID, role string) error {
|
|
_, err := DB.ExecContext(ctx,
|
|
"UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?",
|
|
role, teamID, userID)
|
|
return err
|
|
}
|
|
|
|
func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
|
|
u.email, COALESCE(u.display_name, ''), u.username
|
|
FROM team_members tm
|
|
JOIN users u ON u.id = tm.user_id
|
|
WHERE tm.team_id = ?
|
|
ORDER BY tm.role DESC, u.username`, teamID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []models.TeamMember
|
|
for rows.Next() {
|
|
var m models.TeamMember
|
|
err := rows.Scan(&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
|
|
&m.Email, &m.DisplayName, &m.Username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, m)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *TeamStore) GetMember(ctx context.Context, teamID, userID string) (*models.TeamMember, error) {
|
|
var m models.TeamMember
|
|
err := DB.QueryRowContext(ctx, `
|
|
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
|
|
u.email, COALESCE(u.display_name, ''), u.username
|
|
FROM team_members tm
|
|
JOIN users u ON u.id = tm.user_id
|
|
WHERE tm.team_id = ? AND tm.user_id = ?`, teamID, userID).Scan(
|
|
&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
|
|
&m.Email, &m.DisplayName, &m.Username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
// GetUserTeamIDs returns all team IDs a user belongs to.
|
|
func (s *TeamStore) GetUserTeamIDs(ctx context.Context, userID string) ([]string, error) {
|
|
rows, err := DB.QueryContext(ctx,
|
|
"SELECT team_id FROM team_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()
|
|
}
|
|
|
|
func (s *TeamStore) IsTeamAdmin(ctx context.Context, teamID, userID string) (bool, error) {
|
|
var role string
|
|
err := DB.QueryRowContext(ctx,
|
|
"SELECT role FROM team_members WHERE team_id = ? AND user_id = ?",
|
|
teamID, userID).Scan(&role)
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return role == models.TeamRoleAdmin, nil
|
|
}
|
|
|
|
func (s *TeamStore) IsMember(ctx context.Context, teamID, userID string) (bool, error) {
|
|
var exists bool
|
|
err := DB.QueryRowContext(ctx,
|
|
"SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = ? AND user_id = ?)",
|
|
teamID, userID).Scan(&exists)
|
|
return exists, err
|
|
}
|
|
|
|
|
|
// ── CS1 additions ─────────────────────────────────────────────
|
|
|
|
func (s *TeamStore) Exists(ctx context.Context, teamID string) (bool, error) {
|
|
var count int
|
|
err := DB.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM teams WHERE id = ?`, teamID).Scan(&count)
|
|
return count > 0, err
|
|
}
|
|
|
|
func (s *TeamStore) UpdateMemberRoleByID(ctx context.Context, memberID, teamID, role string) (int64, error) {
|
|
res, err := DB.ExecContext(ctx,
|
|
`UPDATE team_members SET role = ? WHERE id = ? AND team_id = ?`,
|
|
role, memberID, teamID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
func (s *TeamStore) DeleteMemberByID(ctx context.Context, memberID, teamID string) (int64, error) {
|
|
res, err := DB.ExecContext(ctx,
|
|
`DELETE FROM team_members WHERE id = ? AND team_id = ?`,
|
|
memberID, teamID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
func (s *TeamStore) ListTeamAuditActions(ctx context.Context, teamID string) ([]string, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT DISTINCT al.action
|
|
FROM audit_log al
|
|
WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = ?)
|
|
ORDER BY al.action
|
|
`, teamID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var actions []string
|
|
for rows.Next() {
|
|
var a string
|
|
if err := rows.Scan(&a); err != nil {
|
|
return nil, err
|
|
}
|
|
actions = append(actions, a)
|
|
}
|
|
if actions == nil {
|
|
actions = []string{}
|
|
}
|
|
return actions, rows.Err()
|
|
}
|
|
|
|
// ── CS5b additions ────────────────────────────────────────────
|
|
|
|
func (s *TeamStore) GetFirstTeamIDForUser(ctx context.Context, userID string) (string, error) {
|
|
var teamID string
|
|
err := DB.QueryRowContext(ctx,
|
|
`SELECT team_id FROM team_members WHERE user_id = ? LIMIT 1`, userID).Scan(&teamID)
|
|
if err != nil {
|
|
return "", nil
|
|
}
|
|
return teamID, nil
|
|
}
|
|
|
|
// ── CS6 additions ────────────────────────────────────────────
|
|
|
|
func (s *TeamStore) AddMemberReturningID(ctx context.Context, teamID, userID, role string) (string, error) {
|
|
id := store.NewID()
|
|
_, err := DB.ExecContext(ctx, `
|
|
INSERT INTO team_members (id, team_id, user_id, role)
|
|
VALUES (?, ?, ?, ?)`, id, teamID, userID, role)
|
|
return id, err
|
|
}
|
|
|
|
func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON string) error {
|
|
_, err := DB.ExecContext(ctx,
|
|
`UPDATE teams SET settings = json_patch(COALESCE(settings, '{}'), ?) WHERE id = ?`,
|
|
settingsJSON, teamID)
|
|
return err
|
|
}
|
|
|
|
// ── v0.9.3 — Team User Roles (many-to-many) ──────────────────
|
|
|
|
func (s *TeamStore) AddUserRole(ctx context.Context, teamID, userID, role, assignedBy string) error {
|
|
_, err := DB.ExecContext(ctx, `
|
|
INSERT INTO team_user_roles (id, team_id, user_id, role, assigned_by)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT (team_id, user_id, role) DO NOTHING`,
|
|
store.NewID(), teamID, userID, role, assignedBy)
|
|
return err
|
|
}
|
|
|
|
func (s *TeamStore) RemoveUserRole(ctx context.Context, teamID, userID, role string) error {
|
|
_, err := DB.ExecContext(ctx,
|
|
`DELETE FROM team_user_roles WHERE team_id = ? AND user_id = ? AND role = ?`,
|
|
teamID, userID, role)
|
|
return err
|
|
}
|
|
|
|
func (s *TeamStore) ListUserRoles(ctx context.Context, teamID, userID string) ([]string, error) {
|
|
rows, err := DB.QueryContext(ctx,
|
|
`SELECT role FROM team_user_roles WHERE team_id = ? AND user_id = ? ORDER BY role`,
|
|
teamID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var roles []string
|
|
for rows.Next() {
|
|
var r string
|
|
if err := rows.Scan(&r); err != nil {
|
|
return nil, err
|
|
}
|
|
roles = append(roles, r)
|
|
}
|
|
if roles == nil {
|
|
roles = []string{}
|
|
}
|
|
return roles, rows.Err()
|
|
}
|
|
|
|
func (s *TeamStore) GetMemberRoles(ctx context.Context, teamID, userID string) ([]string, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT role FROM team_members WHERE team_id = ? AND user_id = ?
|
|
UNION
|
|
SELECT role FROM team_user_roles WHERE team_id = ? AND user_id = ?
|
|
ORDER BY role`,
|
|
teamID, userID, teamID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var roles []string
|
|
for rows.Next() {
|
|
var r string
|
|
if err := rows.Scan(&r); err != nil {
|
|
return nil, err
|
|
}
|
|
roles = append(roles, r)
|
|
}
|
|
if roles == nil {
|
|
roles = []string{}
|
|
}
|
|
return roles, rows.Err()
|
|
}
|
|
|
|
func (s *TeamStore) HasRole(ctx context.Context, teamID, userID, role string) (bool, error) {
|
|
var count int
|
|
err := DB.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM (
|
|
SELECT 1 FROM team_members WHERE team_id = ? AND user_id = ? AND role = ?
|
|
UNION ALL
|
|
SELECT 1 FROM team_user_roles WHERE team_id = ? AND user_id = ? AND role = ?
|
|
)`, teamID, userID, role, teamID, userID, role).Scan(&count)
|
|
return count > 0, err
|
|
}
|
|
|
|
func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID string) error {
|
|
_, err := DB.ExecContext(ctx,
|
|
`DELETE FROM team_user_roles WHERE team_id = ? AND user_id = ?`,
|
|
teamID, userID)
|
|
return err
|
|
}
|
|
|
|
// ── v0.9.4 — Team Role Catalog ──
|
|
|
|
func (s *TeamStore) AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error {
|
|
_, err := DB.ExecContext(ctx, `
|
|
INSERT INTO team_role_catalog (id, team_id, role, source_package_id)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT (team_id, role) DO NOTHING`,
|
|
uuid.New().String(), teamID, role, sourcePkgID)
|
|
return err
|
|
}
|
|
|
|
func (s *TeamStore) ListRoleCatalog(ctx context.Context, teamID string) ([]store.TeamRoleCatalogEntry, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT role, COALESCE(source_package_id, '') FROM team_role_catalog
|
|
WHERE team_id = ? ORDER BY role`, teamID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var result []store.TeamRoleCatalogEntry
|
|
for rows.Next() {
|
|
var e store.TeamRoleCatalogEntry
|
|
if err := rows.Scan(&e.Role, &e.SourcePackageID); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, e)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *TeamStore) RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error {
|
|
_, err := DB.ExecContext(ctx, `
|
|
DELETE FROM team_role_catalog WHERE team_id = ? AND source_package_id = ?`,
|
|
teamID, sourcePkgID)
|
|
return err
|
|
}
|