Skip to content

Commit 28b0704

Browse files
Merge pull request #164 from flocko-motion/feat/progress
feat: progress
2 parents a5ef376 + f20b6e1 commit 28b0704

File tree

14 files changed

+243
-35
lines changed

14 files changed

+243
-35
lines changed

server/api/httpx/response.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func ErrorCodeToStatus(code string) int {
106106
return http.StatusForbidden
107107
case obj.ErrCodeNotFound:
108108
return http.StatusNotFound
109-
case obj.ErrCodeConflict, obj.ErrCodeDuplicateName:
109+
case obj.ErrCodeConflict, obj.ErrCodeDuplicateName, obj.ErrCodeLastHead:
110110
return http.StatusConflict
111111
case obj.ErrCodeServerError:
112112
return http.StatusInternalServerError

server/db/institution.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,14 +270,22 @@ func DeleteInstitution(ctx context.Context, id uuid.UUID, deletedBy uuid.UUID) e
270270
_ = DeleteUser(ctx, participantID)
271271
}
272272

273-
// 3. Clean up remaining FK references before hard-delete
273+
// 3. Collect non-participant members (head/staff) before removing roles
274+
memberIDs, _ := queries().GetNonParticipantUserIDsByInstitution(ctx, uuid.NullUUID{UUID: id, Valid: true})
275+
276+
// 4. Clean up remaining FK references before hard-delete
274277
_ = queries().DeleteInvitesByInstitution(ctx, id)
275278
_ = queries().DeleteApiKeySharesByInstitution(ctx, uuid.NullUUID{UUID: id, Valid: true})
276279
_ = queries().DeleteUserRolesByInstitution(ctx, uuid.NullUUID{UUID: id, Valid: true})
277280
_ = queries().HardDeleteWorkshopsByInstitution(ctx, id)
278281
_ = queries().ClearInstitutionFreeUseApiKeyShare(ctx, id)
279282

280-
// 4. Hard-delete the institution
283+
// 5. Assign individual role to former members so they aren't left without a role
284+
for _, memberID := range memberIDs {
285+
_ = assignDefaultIndividualRole(ctx, memberID)
286+
}
287+
288+
// 6. Hard-delete the institution
281289
if err := queries().HardDeleteInstitution(ctx, id); err != nil {
282290
return obj.ErrServerError("failed to delete institution")
283291
}

server/db/permissions.go

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,6 @@ func canAccessInstitutionMembers(ctx context.Context, userID uuid.UUID, operatio
113113
// If target is a head, apply additional validations
114114
if targetUser.Role != nil && targetUser.Role.Role == obj.RoleHead &&
115115
targetUser.Role.Institution != nil && targetUser.Role.Institution.ID == institutionID {
116-
// Heads cannot remove themselves
117-
if *targetUserID == userID {
118-
return obj.ErrForbidden("heads cannot remove themselves from an institution")
119-
}
120-
121116
// Count heads in this institution
122117
members, err := GetInstitutionMembers(ctx, institutionID, userID)
123118
if err != nil {
@@ -131,9 +126,9 @@ func canAccessInstitutionMembers(ctx context.Context, userID uuid.UUID, operatio
131126
}
132127
}
133128

134-
// Prevent removing the last head
129+
// Prevent removing the last head (whether self or other)
135130
if headCount <= 1 {
136-
return obj.ErrForbidden("cannot remove the last head from an institution")
131+
return obj.NewAppError(obj.ErrCodeLastHead, "you are the last head of this organization — please contact the site administrators to leave")
137132
}
138133
}
139134

server/db/queries/game.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,9 @@ UPDATE app_user SET private_share_game_id = NULL WHERE private_share_game_id = $
289289
-- name: HardDeleteGamesByCreator :exec
290290
DELETE FROM game WHERE created_by = $1;
291291

292+
-- name: UnlinkGamesFromWorkshop :exec
293+
UPDATE game SET workshop_id = NULL WHERE workshop_id = $1;
294+
292295

293296
-- game_tag -------------------------------------------------------------
294297

server/db/queries/institution.sql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ WHERE r.institution_id = $1
5252
AND r.role = 'participant'
5353
AND u.deleted_at IS NULL;
5454

55+
-- name: GetNonParticipantUserIDsByInstitution :many
56+
SELECT DISTINCT u.id
57+
FROM app_user u
58+
JOIN user_role r ON u.id = r.user_id
59+
WHERE r.institution_id = $1
60+
AND r.role != 'participant'
61+
AND u.deleted_at IS NULL;
62+
5563
-- name: DeleteInvitesByInstitution :exec
5664
DELETE FROM user_role_invite WHERE institution_id = $1;
5765

server/db/sqlc/game.sql.go

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/db/sqlc/institution.sql.go

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/db/workshop.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,9 @@ func DeleteWorkshop(ctx context.Context, id uuid.UUID, deletedBy uuid.UUID) erro
697697
_ = DeleteUser(ctx, uid)
698698
}
699699

700+
// Unlink remaining games from this workshop (member games stay with their creator)
701+
_ = queries().UnlinkGamesFromWorkshop(ctx, wsNullUUID)
702+
700703
err = queries().DeleteWorkshop(ctx, id)
701704
if err != nil {
702705
return obj.ErrServerError("failed to delete workshop")

testing/institution_deletion_test.go

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,16 @@ func (s *InstitutionDeletionTestSuite) TestAdminCanDeleteInstitutionWithCascade(
7575
s.T().Logf("Users after deletion: %d", len(usersAfter))
7676
s.Equal(len(usersBefore)-1, len(usersAfter), "only participant should be removed")
7777

78-
// Head and staff should still exist
78+
// Head and staff should still exist with individual roles
7979
headMe := Must(head.GetMe())
8080
s.Equal(head.Name, headMe.Name, "head should still exist")
81+
s.Require().NotNil(headMe.Role, "head should have a role")
82+
s.Equal(obj.RoleIndividual, headMe.Role.Role, "head should become individual")
8183
staffMe := Must(staff.GetMe())
8284
s.Equal(staff.Name, staffMe.Name, "staff should still exist")
83-
s.T().Logf("Head and staff survived institution deletion")
85+
s.Require().NotNil(staffMe.Role, "staff should have a role")
86+
s.Equal(obj.RoleIndividual, staffMe.Role.Role, "staff should become individual")
87+
s.T().Logf("Head and staff survived institution deletion with individual roles")
8488

8589
// Participant's game should be gone
8690
games := Must(admin.ListGames())
@@ -175,6 +179,125 @@ func (s *InstitutionDeletionTestSuite) TestAdminDeleteOrgMultipleUsersAndWorksho
175179
s.T().Logf("All participant games deleted, all non-participants survived")
176180
}
177181

182+
// TestDeleteInstitutionUnlinksMemberGamesDeletesParticipantGames tests that when an institution
183+
// is deleted, participant games are removed but head/staff games are preserved (unlinked from workshop).
184+
func (s *InstitutionDeletionTestSuite) TestDeleteInstitutionUnlinksMemberGamesDeletesParticipantGames() {
185+
admin := s.DevUser()
186+
187+
inst := Must(admin.CreateInstitution("Game Unlink Org"))
188+
instIDStr := inst.ID.String()
189+
190+
head := s.CreateUser("gu-head")
191+
headInvite := Must(admin.InviteToInstitution(instIDStr, "head", head.ID))
192+
Must(head.AcceptInvite(headInvite.ID.String()))
193+
194+
staff := s.CreateUser("gu-staff")
195+
staffInvite := Must(head.InviteToInstitution(instIDStr, "staff", staff.ID))
196+
Must(staff.AcceptInvite(staffInvite.ID.String()))
197+
198+
// Create workshop with a participant
199+
workshop := Must(head.CreateWorkshop(instIDStr, "Game Unlink Workshop"))
200+
wsIDStr := workshop.ID.String()
201+
202+
invite := Must(head.CreateWorkshopInvite(wsIDStr, string(obj.RoleParticipant)))
203+
resp, err := s.AcceptWorkshopInviteAnonymously(*invite.InviteToken)
204+
s.NoError(err)
205+
participant := s.CreateUserWithToken(*resp.AuthToken)
206+
207+
// Head creates a game (member game — should be preserved)
208+
headGame := Must(head.UploadGame("alien-first-contact"))
209+
s.T().Logf("Head created game: %s (ID: %s)", headGame.Name, headGame.ID)
210+
211+
// Staff creates a game (member game — should be preserved)
212+
staffGame := Must(staff.UploadGame("alien-first-contact"))
213+
s.T().Logf("Staff created game: %s (ID: %s)", staffGame.Name, staffGame.ID)
214+
215+
// Participant creates a game (should be deleted)
216+
participantGame := Must(participant.UploadGame("alien-first-contact"))
217+
s.T().Logf("Participant created game: %s (ID: %s)", participantGame.Name, participantGame.ID)
218+
219+
// Delete the institution
220+
MustSucceed(admin.DeleteInstitution(instIDStr))
221+
s.T().Logf("Admin deleted institution")
222+
223+
// Head's game should still exist (unlinked from workshop)
224+
fetchedHeadGame, err := head.GetGameByID(headGame.ID.String())
225+
s.NoError(err, "head's game should survive institution deletion")
226+
s.Equal(headGame.ID, fetchedHeadGame.ID)
227+
s.Nil(fetchedHeadGame.WorkshopID, "head's game should be unlinked from workshop")
228+
s.T().Logf("Head's game survived and is unlinked")
229+
230+
// Staff's game should still exist (unlinked from workshop)
231+
fetchedStaffGame, err := staff.GetGameByID(staffGame.ID.String())
232+
s.NoError(err, "staff's game should survive institution deletion")
233+
s.Equal(staffGame.ID, fetchedStaffGame.ID)
234+
s.Nil(fetchedStaffGame.WorkshopID, "staff's game should be unlinked from workshop")
235+
s.T().Logf("Staff's game survived and is unlinked")
236+
237+
// Participant's game should be gone (participant user deleted)
238+
_, err = admin.GetGameByID(participantGame.ID.String())
239+
s.Error(err, "participant's game should be deleted with institution")
240+
s.T().Logf("Participant's game correctly deleted")
241+
242+
// Head and staff should now have individual roles (not left without a role)
243+
headMe := Must(head.GetMe())
244+
s.Require().NotNil(headMe.Role, "head should still have a role after institution deletion")
245+
s.Equal(obj.RoleIndividual, headMe.Role.Role, "head should become individual after institution deletion")
246+
s.Nil(headMe.Role.Institution, "head should have no institution after institution deletion")
247+
s.T().Logf("Head has individual role")
248+
249+
staffMe := Must(staff.GetMe())
250+
s.Require().NotNil(staffMe.Role, "staff should still have a role after institution deletion")
251+
s.Equal(obj.RoleIndividual, staffMe.Role.Role, "staff should become individual after institution deletion")
252+
s.Nil(staffMe.Role.Institution, "staff should have no institution after institution deletion")
253+
s.T().Logf("Staff has individual role")
254+
}
255+
256+
// TestDeleteWorkshopUnlinksMemberGames tests that deleting a single workshop
257+
// unlinks member games and deletes participant games.
258+
func (s *InstitutionDeletionTestSuite) TestDeleteWorkshopUnlinksMemberGames() {
259+
admin := s.DevUser()
260+
261+
inst := Must(admin.CreateInstitution("WS Del Org"))
262+
instIDStr := inst.ID.String()
263+
264+
head := s.CreateUser("wsd-head")
265+
headInvite := Must(admin.InviteToInstitution(instIDStr, "head", head.ID))
266+
Must(head.AcceptInvite(headInvite.ID.String()))
267+
268+
workshop := Must(head.CreateWorkshop(instIDStr, "WS Del Workshop"))
269+
wsIDStr := workshop.ID.String()
270+
271+
invite := Must(head.CreateWorkshopInvite(wsIDStr, string(obj.RoleParticipant)))
272+
resp, err := s.AcceptWorkshopInviteAnonymously(*invite.InviteToken)
273+
s.NoError(err)
274+
participant := s.CreateUserWithToken(*resp.AuthToken)
275+
276+
// Head creates a game in the workshop context
277+
headGame := Must(head.UploadGame("alien-first-contact"))
278+
s.T().Logf("Head created game: %s", headGame.ID)
279+
280+
// Participant creates a game
281+
participantGame := Must(participant.UploadGame("alien-first-contact"))
282+
s.T().Logf("Participant created game: %s", participantGame.ID)
283+
284+
// Delete the workshop (not the institution)
285+
MustSucceed(head.DeleteWorkshop(wsIDStr))
286+
s.T().Logf("Head deleted workshop")
287+
288+
// Head's game should still exist (unlinked)
289+
fetchedHeadGame, err := head.GetGameByID(headGame.ID.String())
290+
s.NoError(err, "head's game should survive workshop deletion")
291+
s.Equal(headGame.ID, fetchedHeadGame.ID)
292+
s.Nil(fetchedHeadGame.WorkshopID, "head's game should be unlinked from workshop")
293+
s.T().Logf("Head's game survived and is unlinked")
294+
295+
// Participant's game should be gone
296+
_, err = admin.GetGameByID(participantGame.ID.String())
297+
s.Error(err, "participant's game should be deleted with workshop")
298+
s.T().Logf("Participant's game correctly deleted")
299+
}
300+
178301
// TestHeadCannotDeleteInstitution tests that a head cannot delete their own institution.
179302
func (s *InstitutionDeletionTestSuite) TestHeadCannotDeleteInstitution() {
180303
admin := s.DevUser()

testing/institution_membership_test.go

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -353,13 +353,13 @@ func (s *MultiUserTestSuite) TestInstitutionManagementLeadership() {
353353
s.Equal(2, len(instWithBothHeads.Members), "should have 2 members")
354354
s.T().Logf("Institution has 2 heads again")
355355

356-
// Charlie cannot remove himself - the validation prevents self-removal
357-
MustFail(clientCharlie.RemoveMember(institution.ID.String(), clientCharlie.ID))
358-
s.T().Logf("Charlie cannot remove himself (expected - validation prevents self-removal)")
356+
// Charlie CAN remove himself (leave) since Diana is also a head
357+
MustSucceed(clientCharlie.RemoveMember(institution.ID.String(), clientCharlie.ID))
358+
s.T().Logf("Charlie left the institution (Diana remains as head)")
359359

360-
// Diana also cannot remove herself
360+
// Diana is now the last head — she cannot leave
361361
MustFail(clientDiana.RemoveMember(institution.ID.String(), clientDiana.ID))
362-
s.T().Logf("Diana cannot remove herself (expected - validation prevents self-removal)")
362+
s.T().Logf("Diana cannot leave (expected - she is the last head)")
363363
}
364364

365365
// TestInstitutionManagementLeadershipSteal creates two institutions with one head each
@@ -457,22 +457,19 @@ func (s *MultiUserTestSuite) TestInstitutionManagementLeadershipSteal() {
457457
s.Equal(obj.RoleHead, inst2AfterFrankLeft.Members[0].Role)
458458
s.T().Logf("Institution2 now has 1 head: grace (Frank left)")
459459

460-
// Frank cannot remove himself from institution1 (self-removal validation)
461-
MustFail(clientFrank.RemoveMember(institution1.ID.String(), clientFrank.ID))
462-
s.T().Logf("Frank cannot remove himself from institution1 (validation prevents self-removal)")
463-
464-
// Eve can remove Frank from institution1 (since there are 2 heads)
465-
MustSucceed(clientEve.RemoveMember(institution1.ID.String(), clientFrank.ID))
466-
s.T().Logf("Eve removed Frank from institution1")
460+
// Frank CAN remove himself from institution1 (there are 2 heads: eve and frank)
461+
MustSucceed(clientFrank.RemoveMember(institution1.ID.String(), clientFrank.ID))
462+
s.T().Logf("Frank left institution1 (Eve remains as head)")
467463

468464
// Verify Frank was removed from institution1
469465
inst1AfterRemoval := Must(clientEve.GetInstitution(institution1.ID.String()))
470466
s.Equal(1, len(inst1AfterRemoval.Members), "institution1 should have 1 member")
471467
s.Equal("eve", inst1AfterRemoval.Members[0].Name)
472468
s.T().Logf("Institution1 back to 1 head: eve")
473469

474-
// Frank now has no role in any institution
475-
s.T().Logf("Frank has no role in any institution")
470+
// Eve is now the last head — she cannot leave
471+
MustFail(clientEve.RemoveMember(institution1.ID.String(), clientEve.ID))
472+
s.T().Logf("Eve cannot leave institution1 (she is the last head)")
476473
}
477474

478475
// TestInviteByEmail tests email-based invitations

0 commit comments

Comments
 (0)