|
| 1 | +# 07 — Promote Collaborator to Owner |
| 2 | + |
| 3 | +## Goal / Scope |
| 4 | + |
| 5 | +Implement `POST /api/v1/apps/{app_id}/collaborators/{collaborator_id}/promote`. |
| 6 | + |
| 7 | +The target collaborator becomes owner; the current owner is demoted to editor. Both role changes happen in a single database transaction, with `updated_at` stamped on each updated row. Email resolution happens outside the transaction (Admin API call). |
| 8 | + |
| 9 | +No request body. Returns the promoted collaborator as a `Collaborator` object. |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## Schema Change |
| 14 | + |
| 15 | +Add `updated_at` column to `_portal_app_collaborator`. Initial value is copied from `created_at`. |
| 16 | + |
| 17 | +Migration file: `cmd/portal/cmd/cmddatabase/migrations/portal/20260429120000-add_collaborator_updated_at.sql` |
| 18 | + |
| 19 | +```sql |
| 20 | +-- +migrate Up |
| 21 | + |
| 22 | +ALTER TABLE _portal_app_collaborator ADD COLUMN updated_at timestamp with time zone; |
| 23 | +UPDATE _portal_app_collaborator SET updated_at = created_at; |
| 24 | +ALTER TABLE _portal_app_collaborator ALTER COLUMN updated_at SET NOT NULL; |
| 25 | + |
| 26 | +-- +migrate Down |
| 27 | + |
| 28 | +ALTER TABLE _portal_app_collaborator DROP COLUMN updated_at; |
| 29 | +``` |
| 30 | + |
| 31 | +--- |
| 32 | + |
| 33 | +## Existing Code Audit |
| 34 | + |
| 35 | +No model change. INSERT paths set `updated_at = created_at` using the existing `CreatedAt` field already on the struct (or the `now` variable already in scope). UPDATE paths stamp `now`. |
| 36 | + |
| 37 | +| File | Operation | Change | |
| 38 | +|---|---|---| |
| 39 | +| `pkg/portal/service/collaborator.go` `CreateCollaborator` | INSERT | Add `"updated_at"` column with value `c.CreatedAt` | |
| 40 | +| `pkg/siteadmin/service/collaborator.go` `CreateCollaborator` | INSERT | Add `"updated_at"` column with value `c.CreatedAt` | |
| 41 | +| `cmd/portal/internal/collaborator.go` `insertCollaborator` | INSERT | Add `"updated_at"` column with value `now` (already in scope) | |
| 42 | +| `cmd/portal/internal/collaborator.go` `updateCollaboratorRole` | UPDATE | Add `Set("updated_at", time.Now().UTC())` | |
| 43 | + |
| 44 | +`model.Collaborator`, `NewCollaborator`, `selectCollaborator`, and `scanCollaborator` are **not changed**. |
| 45 | + |
| 46 | +### `pkg/portal/service/collaborator.go` — `CreateCollaborator` |
| 47 | + |
| 48 | +```go |
| 49 | +Columns("id", "app_id", "user_id", "created_at", "updated_at", "role"). |
| 50 | +Values(c.ID, c.AppID, c.UserID, c.CreatedAt, c.CreatedAt, c.Role) |
| 51 | +``` |
| 52 | + |
| 53 | +### `pkg/siteadmin/service/collaborator.go` — `CreateCollaborator` |
| 54 | + |
| 55 | +```go |
| 56 | +s.SQLBuilder. |
| 57 | + Insert(s.SQLBuilder.TableName("_portal_app_collaborator")). |
| 58 | + Columns("id", "app_id", "user_id", "created_at", "updated_at", "role"). |
| 59 | + Values(c.ID, c.AppID, c.UserID, c.CreatedAt, c.CreatedAt, c.Role) |
| 60 | +``` |
| 61 | + |
| 62 | +### `cmd/portal/internal/collaborator.go` — `insertCollaborator` + `updateCollaboratorRole` |
| 63 | + |
| 64 | +`insertCollaborator` (`now` already declared in the function): |
| 65 | +```go |
| 66 | +Columns("id", "app_id", "user_id", "created_at", "updated_at", "role"). |
| 67 | +Values(id, appID, userID, now, now, role) |
| 68 | +``` |
| 69 | + |
| 70 | +`updateCollaboratorRole`: |
| 71 | +```go |
| 72 | +func updateCollaboratorRole(ctx context.Context, tx *sql.Tx, id string, role model.CollaboratorRole) error { |
| 73 | + now := time.Now().UTC() |
| 74 | + builder := newSQLBuilder().Update(pq.QuoteIdentifier("_portal_app_collaborator")). |
| 75 | + Set("role", role). |
| 76 | + Set("updated_at", now). |
| 77 | + Where("id = ?", id) |
| 78 | + ... |
| 79 | +} |
| 80 | +``` |
| 81 | + |
| 82 | +--- |
| 83 | + |
| 84 | +## New Service Methods |
| 85 | + |
| 86 | +### `pkg/siteadmin/service/collaborator.go` |
| 87 | + |
| 88 | +**New error:** |
| 89 | +```go |
| 90 | +var ErrCollaboratorAlreadyOwner = apierrors.AlreadyExists.WithReason("CollaboratorAlreadyOwner").New("collaborator is already the owner") |
| 91 | +``` |
| 92 | + |
| 93 | +**`UpdateCollaborator` added to `CollaboratorServiceStore` interface:** |
| 94 | +```go |
| 95 | +UpdateCollaborator(ctx context.Context, c *model.Collaborator) error |
| 96 | +``` |
| 97 | + |
| 98 | +**`CollaboratorStore.UpdateCollaborator` implementation:** |
| 99 | +```go |
| 100 | +func (s *CollaboratorStore) UpdateCollaborator(ctx context.Context, c *model.Collaborator) error { |
| 101 | + now := s.Clock.NowUTC() |
| 102 | + _, err := s.SQLExecutor.ExecWith(ctx, s.SQLBuilder. |
| 103 | + Update(s.SQLBuilder.TableName("_portal_app_collaborator")). |
| 104 | + Set("role", c.Role). |
| 105 | + Set("updated_at", now). |
| 106 | + Where("id = ?", c.ID), |
| 107 | + ) |
| 108 | + return err |
| 109 | +} |
| 110 | +``` |
| 111 | + |
| 112 | +**`CollaboratorService.PromoteCollaborator`:** |
| 113 | +```go |
| 114 | +func (s *CollaboratorService) PromoteCollaborator(ctx context.Context, appID string, collaboratorID string) (*siteadmin.Collaborator, error) { |
| 115 | + var promoted *model.Collaborator |
| 116 | + err := s.GlobalDatabase.WithTx(ctx, func(ctx context.Context) error { |
| 117 | + target, err := s.Store.GetCollaborator(ctx, collaboratorID) |
| 118 | + if err != nil { |
| 119 | + return err |
| 120 | + } |
| 121 | + if target.AppID != appID { |
| 122 | + return portalservice.ErrCollaboratorNotFound |
| 123 | + } |
| 124 | + if target.Role == model.CollaboratorRoleOwner { |
| 125 | + return ErrCollaboratorAlreadyOwner |
| 126 | + } |
| 127 | + |
| 128 | + all, err := s.Store.ListCollaborators(ctx, appID) |
| 129 | + if err != nil { |
| 130 | + return err |
| 131 | + } |
| 132 | + var currentOwner *model.Collaborator |
| 133 | + for _, c := range all { |
| 134 | + if c.Role == model.CollaboratorRoleOwner { |
| 135 | + currentOwner = c |
| 136 | + break |
| 137 | + } |
| 138 | + } |
| 139 | + |
| 140 | + target.Role = model.CollaboratorRoleOwner |
| 141 | + if err := s.Store.UpdateCollaborator(ctx, target); err != nil { |
| 142 | + return err |
| 143 | + } |
| 144 | + if currentOwner != nil { |
| 145 | + currentOwner.Role = model.CollaboratorRoleEditor |
| 146 | + if err := s.Store.UpdateCollaborator(ctx, currentOwner); err != nil { |
| 147 | + return err |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + promoted = target |
| 152 | + return nil |
| 153 | + }) |
| 154 | + if err != nil { |
| 155 | + return nil, err |
| 156 | + } |
| 157 | + |
| 158 | + emailMap, err := s.AdminAPI.ResolveUserEmails(ctx, []string{promoted.UserID}) |
| 159 | + if err != nil { |
| 160 | + return nil, err |
| 161 | + } |
| 162 | + |
| 163 | + return &siteadmin.Collaborator{ |
| 164 | + Id: promoted.ID, |
| 165 | + AppId: promoted.AppID, |
| 166 | + UserId: promoted.UserID, |
| 167 | + UserEmail: emailMap[promoted.UserID], |
| 168 | + Role: siteadmin.CollaboratorRole(promoted.Role), |
| 169 | + CreatedAt: promoted.CreatedAt, |
| 170 | + }, nil |
| 171 | +} |
| 172 | +``` |
| 173 | + |
| 174 | +--- |
| 175 | + |
| 176 | +## Transport Handler |
| 177 | + |
| 178 | +`pkg/siteadmin/transport/handler_collaborator_promote.go` — replace stub: |
| 179 | + |
| 180 | +```go |
| 181 | +package transport |
| 182 | + |
| 183 | +import ( |
| 184 | + "context" |
| 185 | + "net/http" |
| 186 | + |
| 187 | + "github.com/authgear/authgear-server/pkg/api/siteadmin" |
| 188 | + "github.com/authgear/authgear-server/pkg/util/httproute" |
| 189 | +) |
| 190 | + |
| 191 | +func ConfigureCollaboratorPromoteRoute(route httproute.Route) httproute.Route { |
| 192 | + return route.WithMethods("OPTIONS", "POST"). |
| 193 | + WithPathPattern("/api/v1/apps/:appID/collaborators/:collaboratorID/promote") |
| 194 | +} |
| 195 | + |
| 196 | +type CollaboratorPromoteService interface { |
| 197 | + PromoteCollaborator(ctx context.Context, appID string, collaboratorID string) (*siteadmin.Collaborator, error) |
| 198 | +} |
| 199 | + |
| 200 | +type CollaboratorPromoteHandler struct { |
| 201 | + Service CollaboratorPromoteService |
| 202 | +} |
| 203 | + |
| 204 | +type CollaboratorPromoteParams struct { |
| 205 | + AppID string |
| 206 | + CollaboratorID string |
| 207 | +} |
| 208 | + |
| 209 | +func parseCollaboratorPromoteParams(r *http.Request) CollaboratorPromoteParams { |
| 210 | + return CollaboratorPromoteParams{ |
| 211 | + AppID: httproute.GetParam(r, "appID"), |
| 212 | + CollaboratorID: httproute.GetParam(r, "collaboratorID"), |
| 213 | + } |
| 214 | +} |
| 215 | + |
| 216 | +func (h *CollaboratorPromoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 217 | + params := parseCollaboratorPromoteParams(r) |
| 218 | + |
| 219 | + collaborator, err := h.Service.PromoteCollaborator(r.Context(), params.AppID, params.CollaboratorID) |
| 220 | + if err != nil { |
| 221 | + writeError(w, r, err) |
| 222 | + return |
| 223 | + } |
| 224 | + |
| 225 | + SiteAdminAPISuccessResponse{Body: collaborator}.WriteTo(w) |
| 226 | +} |
| 227 | +``` |
| 228 | + |
| 229 | +## DI Wiring |
| 230 | + |
| 231 | +`pkg/siteadmin/deps.go` — add: |
| 232 | +```go |
| 233 | +wire.Bind(new(transport.CollaboratorPromoteService), new(*siteadminservice.CollaboratorService)), |
| 234 | +``` |
| 235 | + |
| 236 | +--- |
| 237 | + |
| 238 | +## Test Plan |
| 239 | + |
| 240 | +Add `UpdateCollaborator` to `fakeCollaboratorStore` in `pkg/siteadmin/service/collaborator_test.go`: |
| 241 | + |
| 242 | +```go |
| 243 | +updated []*portalmodel.Collaborator |
| 244 | + |
| 245 | +func (f *fakeCollaboratorStore) UpdateCollaborator(_ context.Context, c *portalmodel.Collaborator) error { |
| 246 | + f.updated = append(f.updated, c) |
| 247 | + if existing, ok := f.existingByID[c.ID]; ok { |
| 248 | + existing.Role = c.Role |
| 249 | + } |
| 250 | + return nil |
| 251 | +} |
| 252 | +``` |
| 253 | + |
| 254 | +Four new test cases under `TestCollaboratorService`: |
| 255 | + |
| 256 | +1. **PromoteCollaborator succeeds** — target is editor, current owner exists; after call: target role is owner, old owner role is editor; returned collaborator has resolved email; AdminAPI called outside TX. |
| 257 | +2. **PromoteCollaborator returns not found when collaboratorID is missing** — `existingByID` is empty. |
| 258 | +3. **PromoteCollaborator returns not found on cross-app access** — collaborator exists but `AppID != appID`. |
| 259 | +4. **PromoteCollaborator returns AlreadyOwner when target is already owner** — collaborator has `Role == owner`. |
| 260 | + |
| 261 | +--- |
| 262 | + |
| 263 | +## Error Table |
| 264 | + |
| 265 | +| Condition | Error | HTTP | |
| 266 | +|---|---|---| |
| 267 | +| collaboratorID not in DB | `portalservice.ErrCollaboratorNotFound` | 404 | |
| 268 | +| collaborator belongs to different app | `portalservice.ErrCollaboratorNotFound` | 404 | |
| 269 | +| collaborator is already owner | `ErrCollaboratorAlreadyOwner` | 409 | |
| 270 | + |
| 271 | +--- |
| 272 | + |
| 273 | +## Atomic Commit Plan |
| 274 | + |
| 275 | +### Commit 1 — DB migration: add updated_at to _portal_app_collaborator |
| 276 | + |
| 277 | +Files: |
| 278 | +- `cmd/portal/cmd/cmddatabase/migrations/portal/20260429120000-add_collaborator_updated_at.sql` |
| 279 | + |
| 280 | +Standalone SQL-only commit. |
| 281 | + |
| 282 | +### Commit 2 — Fix all INSERT and UPDATE paths for updated_at (no model change) |
| 283 | + |
| 284 | +Files: |
| 285 | +- `pkg/portal/service/collaborator.go` — update `CreateCollaborator` (add `updated_at = c.CreatedAt`) |
| 286 | +- `pkg/siteadmin/service/collaborator.go` — update `CreateCollaborator` (add `updated_at = c.CreatedAt`) |
| 287 | +- `cmd/portal/internal/collaborator.go` — update `insertCollaborator` (add `updated_at = now`), `updateCollaboratorRole` (add `Set("updated_at", now)`) |
| 288 | + |
| 289 | +Verification: `go build ./pkg/portal/... ./pkg/siteadmin/... ./cmd/portal/...` · `make fmt` |
| 290 | + |
| 291 | +### Commit 3 — Siteadmin service: UpdateCollaborator + ErrCollaboratorAlreadyOwner + PromoteCollaborator + tests |
| 292 | + |
| 293 | +Files: |
| 294 | +- `pkg/siteadmin/service/collaborator.go` — add `ErrCollaboratorAlreadyOwner`, `UpdateCollaborator` to interface and store, implement `CollaboratorService.PromoteCollaborator` |
| 295 | +- `pkg/siteadmin/service/collaborator_test.go` — add `updated` field + `UpdateCollaborator` fake, add 4 test cases |
| 296 | + |
| 297 | +Verification: `go test ./pkg/siteadmin/service/...` · `make fmt` |
| 298 | + |
| 299 | +### Commit 4 — Transport interface + handler body + DI wiring + wire gen + check-tidy |
| 300 | + |
| 301 | +The handler stub was scaffolded in Stage 3. This commit replaces it with the real implementation and wires everything up. |
| 302 | + |
| 303 | +Files: |
| 304 | +- `pkg/siteadmin/transport/handler_collaborator_promote.go` — add `CollaboratorPromoteService` interface, `Service` field, and `ServeHTTP` body |
| 305 | +- `pkg/siteadmin/deps.go` — add `wire.Bind(new(transport.CollaboratorPromoteService), new(*siteadminservice.CollaboratorService))` |
| 306 | +- `pkg/siteadmin/wire_gen.go` — regenerated via `go generate ./pkg/siteadmin/...` |
| 307 | +- `.vettedpositions` — add new `r.Context()` positions, run `make sort-vettedpositions` |
| 308 | + |
| 309 | +Run `make check-tidy`; stage regenerated/formatted output files and include in this commit. |
| 310 | + |
| 311 | +Verification: `go test ./pkg/siteadmin/...` · `make fmt` · `make lint` · `make check-tidy` |
0 commit comments