Skip to content

Commit 43dea2e

Browse files
authored
add external id sync and optional create for orgs (#2336)
1 parent bf64338 commit 43dea2e

File tree

6 files changed

+274
-59
lines changed

6 files changed

+274
-59
lines changed

taco/internal/api/internal.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) {
5656

5757
// Organization endpoints
5858
internal.POST("/orgs", orgHandler.CreateOrganization)
59+
internal.POST("/orgs/sync", orgHandler.SyncExternalOrg)
5960
internal.GET("/orgs/:orgId", orgHandler.GetOrganization)
6061
internal.GET("/orgs", orgHandler.ListOrganizations)
6162

taco/internal/api/org_handler.go

Lines changed: 162 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
package api
22

33
import (
4+
"context"
45
"errors"
56
"log/slog"
67
"net/http"
7-
"context"
8+
"strings"
89

910
"github.com/diggerhq/digger/opentaco/internal/domain"
1011
"github.com/diggerhq/digger/opentaco/internal/rbac"
@@ -29,17 +30,19 @@ func NewOrgHandler(orgRepo domain.OrganizationRepository, userRepo domain.UserRe
2930

3031
// CreateOrgRequest is the request body for creating an organization
3132
type CreateOrgRequest struct {
32-
Name string `json:"name" validate:"required"` // Unique identifier (e.g., "acme")
33-
DisplayName string `json:"display_name" validate:"required"` // Friendly name (e.g., "Acme Corp")
33+
Name string `json:"name" validate:"required"` // Unique identifier (e.g., "acme")
34+
DisplayName string `json:"display_name" validate:"required"` // Friendly name (e.g., "Acme Corp")
35+
ExternalOrgID string `json:"external_org_id"` // External org identifier (optional)
3436
}
3537

3638
// CreateOrgResponse is the response for creating an organization
3739
type CreateOrgResponse struct {
38-
ID string `json:"id"` // UUID
39-
Name string `json:"name"` // Unique identifier
40-
DisplayName string `json:"display_name"` // Friendly name
41-
CreatedBy string `json:"created_by"`
42-
CreatedAt string `json:"created_at"`
40+
ID string `json:"id"` // UUID
41+
Name string `json:"name"` // Unique identifier
42+
DisplayName string `json:"display_name"` // Friendly name
43+
ExternalOrgID string `json:"external_org_id"` // External org identifier
44+
CreatedBy string `json:"created_by"`
45+
CreatedAt string `json:"created_at"`
4346
}
4447

4548
// CreateOrganization handles POST /internal/orgs
@@ -102,7 +105,7 @@ func (h *OrgHandler) CreateOrganization(c echo.Context) error {
102105

103106
// Create organization in transaction
104107
err := h.orgRepo.WithTransaction(ctx, func(ctx context.Context, txRepo domain.OrganizationRepository) error {
105-
createdOrg, err := txRepo.Create(ctx, req.Name, req.DisplayName, userIDStr)
108+
createdOrg, err := txRepo.Create(ctx, req.Name, req.Name, req.DisplayName, req.ExternalOrgID, userIDStr)
106109
if err != nil {
107110
return err
108111
}
@@ -122,9 +125,17 @@ func (h *OrgHandler) CreateOrganization(c echo.Context) error {
122125
"error": err.Error(),
123126
})
124127
}
128+
129+
// Check for external org ID conflict
130+
if strings.Contains(err.Error(), "external org ID already exists") {
131+
return c.JSON(http.StatusConflict, map[string]string{
132+
"error": err.Error(),
133+
})
134+
}
125135

126136
slog.Error("Failed to create organization",
127137
"name", req.Name,
138+
"externalOrgID", req.ExternalOrgID,
128139
"error", err,
129140
)
130141
return c.JSON(http.StatusInternalServerError, map[string]string{
@@ -162,11 +173,148 @@ func (h *OrgHandler) CreateOrganization(c echo.Context) error {
162173

163174
// Success - org created (and RBAC initialized if available)
164175
return c.JSON(http.StatusCreated, CreateOrgResponse{
165-
ID: org.ID,
166-
Name: org.Name,
167-
DisplayName: org.DisplayName,
168-
CreatedBy: org.CreatedBy,
169-
CreatedAt: org.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
176+
ID: org.ID,
177+
Name: org.Name,
178+
DisplayName: org.DisplayName,
179+
ExternalOrgID: org.ExternalOrgID,
180+
CreatedBy: org.CreatedBy,
181+
CreatedAt: org.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
182+
})
183+
}
184+
185+
// SyncExternalOrgRequest is the request body for syncing an external organization
186+
type SyncExternalOrgRequest struct {
187+
Name string `json:"name" validate:"required"` // Internal name (e.g., "acme")
188+
DisplayName string `json:"display_name" validate:"required"` // Friendly name (e.g., "Acme Corp")
189+
ExternalOrgID string `json:"external_org_id" validate:"required"` // External org identifier
190+
}
191+
192+
// SyncExternalOrgResponse is the response for syncing an external organization
193+
type SyncExternalOrgResponse struct {
194+
Status string `json:"status"` // "created" or "existing"
195+
Organization *domain.Organization `json:"organization"`
196+
}
197+
198+
// SyncExternalOrg handles POST /internal/orgs/sync
199+
// Creates a new organization with external mapping or returns existing one
200+
func (h *OrgHandler) SyncExternalOrg(c echo.Context) error {
201+
ctx := c.Request().Context()
202+
203+
// Get user context from webhook middleware
204+
userID := c.Get("user_id")
205+
email := c.Get("email")
206+
207+
if userID == nil || email == nil {
208+
slog.Error("Missing user context in sync org request")
209+
return c.JSON(http.StatusBadRequest, map[string]string{
210+
"error": "user context required",
211+
})
212+
}
213+
214+
userIDStr, ok := userID.(string)
215+
if !ok || userIDStr == "" {
216+
slog.Error("Invalid user_id type in context")
217+
return c.JSON(http.StatusInternalServerError, map[string]string{
218+
"error": "invalid user context - webhook middleware misconfigured",
219+
})
220+
}
221+
222+
// Parse request
223+
var req SyncExternalOrgRequest
224+
if err := c.Bind(&req); err != nil {
225+
slog.Error("Failed to bind sync org request", "error", err)
226+
return c.JSON(http.StatusBadRequest, map[string]string{
227+
"error": "invalid request body",
228+
})
229+
}
230+
231+
if req.Name == "" || req.DisplayName == "" || req.ExternalOrgID == "" {
232+
return c.JSON(http.StatusBadRequest, map[string]string{
233+
"error": "name, display_name, and external_org_id are required",
234+
})
235+
}
236+
237+
slog.Info("Syncing external organization",
238+
"name", req.Name,
239+
"displayName", req.DisplayName,
240+
"externalOrgID", req.ExternalOrgID,
241+
"createdBy", userIDStr,
242+
)
243+
244+
// Check if external org ID already exists
245+
existingOrg, err := h.orgRepo.GetByExternalID(ctx, req.ExternalOrgID)
246+
if err == nil {
247+
// External org ID exists, return existing org
248+
slog.Info("External organization already exists",
249+
"externalOrgID", req.ExternalOrgID,
250+
"orgID", existingOrg.ID,
251+
)
252+
return c.JSON(http.StatusOK, SyncExternalOrgResponse{
253+
Status: "existing",
254+
Organization: existingOrg,
255+
})
256+
}
257+
258+
if err != domain.ErrOrgNotFound {
259+
slog.Error("Failed to check existing external org ID",
260+
"externalOrgID", req.ExternalOrgID,
261+
"error", err,
262+
)
263+
return c.JSON(http.StatusInternalServerError, map[string]string{
264+
"error": "failed to check existing external organization",
265+
})
266+
}
267+
268+
// Create new organization with external mapping
269+
var org *domain.Organization
270+
err = h.orgRepo.WithTransaction(ctx, func(ctx context.Context, txRepo domain.OrganizationRepository) error {
271+
createdOrg, err := txRepo.Create(ctx, req.Name, req.Name, req.DisplayName, req.ExternalOrgID, userIDStr)
272+
if err != nil {
273+
return err
274+
}
275+
org = createdOrg
276+
return nil
277+
})
278+
279+
if err != nil {
280+
if errors.Is(err, domain.ErrOrgExists) {
281+
return c.JSON(http.StatusConflict, map[string]string{
282+
"error": "organization name already exists",
283+
})
284+
}
285+
if errors.Is(err, domain.ErrInvalidOrgID) {
286+
return c.JSON(http.StatusBadRequest, map[string]string{
287+
"error": err.Error(),
288+
})
289+
}
290+
291+
// Check for external org ID conflict
292+
if strings.Contains(err.Error(), "external org ID already exists") {
293+
return c.JSON(http.StatusConflict, map[string]string{
294+
"error": err.Error(),
295+
})
296+
}
297+
298+
slog.Error("Failed to create organization during sync",
299+
"name", req.Name,
300+
"externalOrgID", req.ExternalOrgID,
301+
"error", err,
302+
)
303+
return c.JSON(http.StatusInternalServerError, map[string]string{
304+
"error": "failed to create organization",
305+
"detail": err.Error(),
306+
})
307+
}
308+
309+
slog.Info("External organization synced successfully",
310+
"name", req.Name,
311+
"externalOrgID", req.ExternalOrgID,
312+
"orgID", org.ID,
313+
)
314+
315+
return c.JSON(http.StatusCreated, SyncExternalOrgResponse{
316+
Status: "created",
317+
Organization: org,
170318
})
171319
}
172320

taco/internal/domain/organization.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@ var OrgIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*[a-zA-Z0-9]$`)
2424
// Organization represents an organization in the domain layer
2525
// This is the domain model, separate from database entities
2626
type Organization struct {
27-
ID string // UUID (primary key, for API)
28-
Name string // Unique identifier (e.g., "acme") - used in CLI and paths
29-
DisplayName string // Friendly name (e.g., "Acme Corp") - shown in UI
30-
CreatedBy string
31-
CreatedAt time.Time
32-
UpdatedAt time.Time
27+
ID string // UUID (primary key, for API)
28+
Name string // Unique identifier (e.g., "acme") - used in CLI and paths
29+
DisplayName string // Friendly name (e.g., "Acme Corp") - shown in UI
30+
ExternalOrgID string // External org identifier (empty string if not set)
31+
CreatedBy string
32+
CreatedAt time.Time
33+
UpdatedAt time.Time
3334
}
3435

3536
// ============================================
@@ -39,8 +40,9 @@ type Organization struct {
3940
// OrganizationRepository defines the interface for organization data access
4041
// Implementations live in the repositories package
4142
type OrganizationRepository interface {
42-
Create(ctx context.Context, orgID, name, createdBy string) (*Organization, error)
43+
Create(ctx context.Context, orgID, name, displayName, externalOrgID, createdBy string) (*Organization, error)
4344
Get(ctx context.Context, orgID string) (*Organization, error)
45+
GetByExternalID(ctx context.Context, externalOrgID string) (*Organization, error)
4446
List(ctx context.Context) ([]*Organization, error)
4547
Delete(ctx context.Context, orgID string) error
4648

taco/internal/query/types/models.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,13 @@ func (rut *RuleUnitTag) BeforeCreate(tx *gorm.DB) error {
106106
}
107107

108108
type Organization struct {
109-
ID string `gorm:"type:varchar(36);primaryKey"`
110-
Name string `gorm:"type:varchar(255);not null;uniqueIndex"` // Unique identifier (e.g., "acme") - used in CLI and paths
111-
DisplayName string `gorm:"type:varchar(255);not null"` // Friendly name (e.g., "Acme Corp") - shown in UI
112-
CreatedBy string `gorm:"type:varchar(255);not null"`
113-
CreatedAt time.Time
114-
UpdatedAt time.Time
109+
ID string `gorm:"type:varchar(36);primaryKey"`
110+
Name string `gorm:"type:varchar(255);not null;uniqueIndex"` // Unique identifier (e.g., "acme") - used in CLI and paths
111+
DisplayName string `gorm:"type:varchar(255);not null"` // Friendly name (e.g., "Acme Corp") - shown in UI
112+
ExternalOrgID *string `gorm:"type:varchar(500);uniqueIndex"` // External org identifier (optional, nullable)
113+
CreatedBy string `gorm:"type:varchar(255);not null"`
114+
CreatedAt time.Time
115+
UpdatedAt time.Time
115116
}
116117

117118
func (o *Organization) BeforeCreate(tx *gorm.DB) error {

taco/internal/repositories/identifier_resolver.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,31 @@ func (r *gormIdentifierResolver) ResolveOrganization(ctx context.Context, identi
3636
return parsed.UUID, nil
3737
}
3838

39-
// Query by name (organizations.name is the short identifier like "default")
39+
// Try to resolve by internal name first
4040
var result struct{ ID string }
4141
err = r.db.WithContext(ctx).
4242
Table("organizations").
4343
Select("id").
4444
Where("name = ?", parsed.Name).
4545
First(&result).Error
4646

47-
if err != nil {
48-
if err == gorm.ErrRecordNotFound {
49-
return "", fmt.Errorf("organization not found: %s", parsed.Name)
50-
}
51-
return "", err
47+
if err == nil {
48+
return result.ID, nil
5249
}
5350

54-
return result.ID, nil
51+
// If not found by name, try external org ID
52+
// This handles cases where someone passes an external ID directly
53+
err = r.db.WithContext(ctx).
54+
Table("organizations").
55+
Select("id").
56+
Where("external_org_id = ?", parsed.Name).
57+
First(&result).Error
58+
59+
if err == nil {
60+
return result.ID, nil
61+
}
62+
63+
return "", fmt.Errorf("organization not found: %s", parsed.Name)
5564
}
5665

5766
// ResolveUnit resolves unit identifier to UUID within an organization

0 commit comments

Comments
 (0)