diff --git a/api.yaml b/api.yaml index 8a2e807..44cb2f5 100644 --- a/api.yaml +++ b/api.yaml @@ -1435,6 +1435,7 @@ components: enum: - Institution - Consortium + - Branch parent: type: string format: uuid @@ -1496,6 +1497,7 @@ components: enum: - Institution - Consortium + - Branch nullable: true parent: type: string diff --git a/api/directory.gen.go b/api/directory.gen.go index ed006c5..641be2c 100644 --- a/api/directory.gen.go +++ b/api/directory.gen.go @@ -60,12 +60,14 @@ const ( // Defines values for EntryType. const ( + EntryTypeBranch EntryType = "Branch" EntryTypeConsortium EntryType = "Consortium" EntryTypeInstitution EntryType = "Institution" ) // Defines values for EntryPatchType. const ( + EntryPatchTypeBranch EntryPatchType = "Branch" EntryPatchTypeConsortium EntryPatchType = "Consortium" EntryPatchTypeInstitution EntryPatchType = "Institution" ) @@ -4566,49 +4568,49 @@ var swaggerSpec = []string{ "GpMMCXXapRmkGZJZ8SBzzmynYcU1XbA0ZKu75K0DPD3UMQy62NEhklZ3CaPdpMs7+qnAny4jD/ucKeKr", "KwO5HZpbUVbHc1gTGrqfsCDidExkuAXxQH14n050kbsS1lz3DZw8HAILQFwvLolUH0FyoZz7hmv5lrMF", "XfYd7jIfaGdplKLpNMjDtTJLRbKm7BLYUq3w+SsHnczi8+FczgD9ppUJObe5WBJG/2PonLn5HhGRosBe", - "/xitOIMrg//cTtKkhyPUxeamLvhA02rEoHVMTjGAG4qu4d+cuaVVRywzJhVVcVrneMuZ1iAar/tRimGe", - "EX6rf2nNyXKYUYsx73ROmAXiw0EQVtDtIjB7vEWiVsCMbJE+5h0yzpRMcBfhpgUhbR1z7HpaXnEYknkI", - "bRaxbSAavkE1OrUMb41WvePz6NU/8qnRbPCZsxDXS5I7QvVPK0esIvEcFLvSQ9zVT+EOab2kDAxxzTDU", - "u3J7WOqfWg1TveO3C1uD1aEcdXppGROFehbbtHkbdwnuyCOPSol2Uae2LA4WMcescNfFsENGmwzfPDHU", - "WMvbbX1qUMH1suywarzzfYjUTMH6PdO6HFSui6wuVKn75wrMjYm50rHTCfMBUYkgXSInYc55CIRpGkhR", - "9K2u94ZIQJ8/XhpFWgG6/HCLLm5mLuX2V+Dfz9jW1Jr5J5T10WrGXcfqiRvxWPXttBB8fbEE5jss3/6O", - "fB4AiiUE6NsK7GWKvfWhEhFfUbZERJqfZRxFYWJ+sEs6WFhseBGrlXYINuI4tq88R4rfA0NcIF+AcSQk", - "lKYIrXcuHaO+p4e/n3AS0RN9jiWwE/iuBDlRZJnVRBX4SjMYa7YaldY8zEJhhfEY19meDbNsUhxR5odx", - "AIgydPV2doM+wtcYpBElWoOUZAnOa6LQXMN9liDGSjyWIJCd3iduURDzhs5nQRbniwPe/uv26vOHN+8/", - "Nk76BxfZYZA5TbqY9IwIzPkpC4y82BKt+DfLDgUiEqDMoDmdo7bSW07ZDfXv4yhj7FhulERAUGSWQmEm", - "JKPBa3KvCXSepY9r6aRbn0fwKQ3VBfP0iLF8k3oppDe1t352oOJaz1vqnU16mqTckCWMJWUbIkDcECU4", - "uyFKgahZy+zq9tPJYz7S4qZNg6wXjSEvEPf9WAhgPkhEBCABUUh87YSoWmUUFq4GzW5nl91kVrSqSucH", - "Qhm6pHNBDBioUndT06GaPyxcYIMoFz3GR9JucmaXl+h6saA+bE/MEF+s+M5c/4Cz1+FKGo8rMagTPLQl", - "yS4E0Rn/B8b1wXG5L6yOCoJNRF0LRsMix3Dfv5UrHu0kRzixEa5mjLmPMMVOSxmQZbVW9/wifzvoTSJr", - "uyyIBOWCqqTikMo9WwGPdcaZr2m7AdzJVem0pZXv2hl2oBxrd8W8+v3HDhsDRveJ9PaH1MtbtRYCZ/Xr", - "gJ0OI8nt73zovLNpLcx0891Mz9tHso2cqmLvPo5bQ2yhq87pUvnrkPrQRVyX9GO1Kvm4CnlRBMInElCe", - "PLYUC7eZW280sgt5JYL0AdzFuZGBw+dSjXbhe4w3ITxAWAW5UhEWEGGLcLbWCd+jFBIyvYHmjYjlCntY", - "gs/16MRU+73y5FgsodIAOcKWC2pCTliJkvRPn0fJwOu+Sqyzx83dgJGGywccsK64k5KiqZWwBTdMpirU", - "z95RAb7iIqFyldbRHkBIq0yvXp69PDMXAhEwElF8jn8zP3mmc9hQfVq+k1q6Or0/gooFQyQMUdY5g9SK", - "2BKDqYasiMw6lBXHZjuRXz7gv4PK5pmNi+73L/Wt7iH5xkWgl0ELGioQpWbnWgv5V9zR2ax1NjEM0saD", - "25xbQcyp7cMfMDBtiN/caeFZXTK8+/XsLHUdKr1JIRrwWph7+lfa6VRQPODar1BWI/sqr3JZhFQqJPKR", - "Hn7doETBd3UahYTWaHD0g9ertUGWbNqVX+1q5QurLwEwCoFe+/93R/WM6YyFhEiCeACBQAhuQbOM12si", - "EquVFZU2cDz34xVGm6IMIojBt2x4Q8kvgqB4lHLsDQ+SXatEhyaY9wSCoPECwKahqK92RtUs6CIoV0oP", - "h5TZlpvCG7xJ7A1Jm7cwz73a6zI6ZmqK7MIv5zxIfjmlgRHu5yggCkq9qdWV48rj4QtvJpMaZlJOQ9FD", - "8hhz+kiDjbWwEJRDRu/M724ZfXHfRpa0P13V+XIMDTrfjOmDx01v/9rhKVJSLB3B5IsHKo6Vei7JeYJm", - "7zQZKRxpcxCj9WMBOon6Mepx9iM9/08X/f/gYk6DAJhd9/Wu1r3iCi14zA6hxRYnS0Saihxl2Xs9iQ2s", - "w2xDFZ87Qla/utuAtz993xvISXt9mmKw/AjQgkIYyAFIp8NJW+78xE76mZhNwwgMYgD7bkNPUioNhE/H", - "mrYEe8mbSAXrriT1pStLTd+nmJLUYcZaf/3EIX/TTjUlqE8LJ4WG9+WoMsXeWZtdI0V9nz7Yh+9OXwhq", - "U4KjSU4tOS2pqXn4Jpm9a+LOZfq0kTneQ5IF1PSl1+4ENX/jyZWePm2LKVUdlapW7aUcd04f7yHZnD4a", - "bg/IWF1i+9LrpWufsBjg1xufkBiWmNo2kikt3SotDbISe/75jZ709JiU4exH+fefP74/uzTUqbjFd2UG", - "JKasBUp8bo1VP1DP9wRh9pp8WkFMqedxpZ6sFwGclt8abvP7WVvVH1w8zxDQ6BtzydOOeS5J3zPR9Oy2", - "M5OgeZWDoCV9AIbyTzlkuWUjb0ynHV6xd+/z65/tagU4mWIfWSKbkTWZ2pGY2kUQ6IjSVJqqtfWHmoGX", - "pcdgm577LqJ0ensGW6LNMOWBbmkzqtJ02NKU+8DJdg5oO2nG3TAfI6LBBpR/zaQNqJlGwGeL0qptjg5J", - "6AETPjtKfGZkNxKc6TnPGZnZ3tk2/GOU+cgwmaFpsq1jBGS5uowKJgOh2MEtsQWHZYc+IhBmSJoQ2KGM", - "ZSgAM2LqQl/JiaMy1t6qUYV1a2I+TTuqc6OtcSP/TNnUvjGi2D2koleR2fS6wdO7OapG8CE1gqHNHdXZ", - "HyE0wpcrGrV2fFzl3wnc263JsymfOfpB0jHdbSHlQdO7BXtu2KjIzhWMBgK2mnUMaK+t7nww+FRV3qmx", - "Y6syU5WJw9o62s38iBTm7EC+fUp5j60HhLmVvHCY9VppH3Q3CcFOcbspeU2gfYQ99hZ4C1FNcH1XcN0o", - "/nZY3UwdBNQ/2Q8+7A2l/+yl1NHN2nr6BMl/HCRPFawaXsaA8dQCBgOrdPxBYXi5ljl52G0wuOHgMADe", - "YtHHoiRnP9xhT6D7WEF3Wau1SxxYKTegIxs7FlJPVfCppfU4P7STa/+wD+0U/2+btm7UPQHljmrH0dWw", - "e6rXRbEq6GpY3/JDO6XPrrreZHSXVSfkvesP7bByFXy73tFhWKqk/fv+ssTIFs7JF48D3YNL3qP0gxX6", - "8XN+aGeA558+tHNUbzg6ytpDCtoGj5iBY+H1VKzedSPyVKF+Gqy2+j4MU6d1EGcH8Z7QdFsB47hqzV1V", - "5rTgFDiLzI5y5RDonH112oWbRy45geZtQHNRpx7b3zsMC2XqfWigPJWmt0fJw4rSw3VCpTrxc4LjTkc+", - "weKjgsWVwrMeYyZZzYxFiM/xqZABFXhzt/lvAAAA//9b5GPinowAAA==", + "/xitOIMrg//cTtKkhyPUxeamLvhA02rEoHVMTjGAG4qu4d+cuaVVRywzJhVVcVrneMuZ1iAarzVuEYT5", + "q364YrhotKDV0bQmZzneqAWbdzo5zCLy4bAIK+h2EZg93iJjK/BGtkgf8w4ZcEq2uIu40wKVtg4+dj0t", + "rzgMyTyENtPYNiIN36AaplqGt4at3vF5GOsf+dSwNvjMWazrJckdqvqnlUNXkYEOCmLpIe7qp3DHtl5S", + "Bsa6ZjzqXbk9PvVPrcar3vHbxa/B6lAOP720bBWOelbdtLkdd1HuyEOQSol2Uae2LBcWwcescNfFsEOG", + "nQzxPDHmWBPcbcVqUAn2suy5arzzfYjUTMH6PdO6HFQukKwuVKn75wrMHYq55LHTCfMBUYkgXSInYc55", + "CIRpGkhRBq6u94ZIQJ8/XhpFWgG6/HCLLm5mLuX2V+Dfz9jW1Jr5J5T10WrGXcfqiRvxWPXttBB8fbEE", + "5jss3/6OfB4AiiUE6NsK7PWKvQeiEhFfUbZERJqfZRxFYWJ+sEs6WFhseBGrlXYINvQ4tq88R4rfA0Nc", + "IF+AcSQklKYsrXcuHaO+p4e/n3AS0RN9jiWwE/iuBDlRZJlVSRX4SjMYa7YaldY8zGJihfEY19meDbNs", + "UhxR5odxAIgydPV2doM+wtcYpBElWoOUZAnOi6PQXMx9liDGSjyWIJCd3iduURDzhs5nQRbwiwPe/uv2", + "6vOHN+8/Nk76BxfZYZA5TbqY9IwIzPkpC4y82BKt+DfLDgUiEqDMoDmdo7ZiXE7ZDfXv4yhj7FhulERA", + "UGSWQmEmJKPBa3KvCXSepY9r6aRbn0fwKY3ZBfP0iLF8k3oppDe194B2oOJaz1sqoE16mqTckCWMJWUb", + "IkDcECU4uyFKgahZy+zq9tPJYz7SAqhNg6wXjSEvEPf9WAhgPkhEBCABUUh87YSoWmUUFq4GzW5nl91k", + "VrSqSucHQhm6pHNBDBioUndT06GaPyxcYIMoFz3GR9JucmaXl+h6saA+bE/MEF+s+M5c/4Cz1+FKGo8r", + "MagTPLRlyy4E0Rn/B8b1wXG5L6yOCoJNRF0LRsMix3Dfv5UrHu0kRzixEa5mjLmPMMVOSxmQZbWW+fwi", + "kTvo3SJruz6IBOWCqqTikMpdXAGPdcaZr2n7A9zJlV9OW/OV79oZdqAca3dVvfqNyA5bBUZ3jvR2jNTr", + "XLWmAmcZ7IC9DyPJ7e+F6LzFaa3QdPPdTM8bSrKNnKpib0OOW0NsxavO6VId7JD60EVcl/RjtSr5uAp5", + "UQTCJxJQnjy2VA23mVtvPbILeSWC9AHcxbmRgcPnUo124XuMNyE8QFgFuVIRFhBhi3C26AnfoxQSMr2B", + "5o2I5Qp7WILP9ejElP298uRYLKHSEjnClgtqQk5YiZL0T59HycB7v0qss8fN3YCRhssHHLCuuJOSoqmV", + "sAU3TKYq1M/eUQG+4iKhcpXW0R5ASKtMr16evTwzNwMRMBJRfI5/Mz95ppfYUH1avpxaunq/P4KKBUMk", + "DFHWS4PUitgSg6mGrIjMepYVx2Y7kd9C4L+DyuaZjYt++C/1re4h+cZFoJdBCxoqEKX251pT+Vfc0eus", + "dTYxDNLGg9ucW0HMqe3MHzAwbZHf3GnhWV0yvPv17Cx1HSq9UiEa8FqYe/pX2vtUUDzg/q9QViP7Kq9y", + "WYRUKiTykR5+3aBEwXd1GoWE1mhwdIjXq7VBlmzalV/tauULqy8BMAqBXvv/d0f1jOmMhYRIgngAgUAI", + "bkGzjNdrIhKrlRWVNnA89+MVRpuiDCKIwbdseEPJL4KgeJRy7A0Pkl2rRIcmmDcHgqDxSsCmoaivdkbV", + "LOgiKFdKD4eU2Sacwhu8SewNSZu3MM+92gs0OmZqiuzCL+c8SH45pYER7ucoIApK3arVlePK4+ELbyaT", + "GmZSTkPRQ/IYc/pIg421sBCUQ0bvzO9uGX1x30aWtD9d1fm6DA0635Xpg8dNb//a4SlSUiwdweSLByqO", + "lXouyXmCZu80GSkcaXMQo/VjATqJ+jHqcfYjPf9PF/3/4GJOgwCYXff1rta94goteMwOocUWJ0tEmooc", + "Zdl7PYkNrMNsQxWfO0JWv7rbgLc/fd8byEmbfppisPwI0IJCGMgBSKfDSVvu/MRO+pmYTcMIDGIA+7ZD", + "T1IqDYRPx5q2BHvJm0gF664k9aUrS03fsJiS1GHGWn8hxSF/0041JahPCyeFhvflqDLF3lmbXSNFfZ8+", + "2IfvTl8RalOCo0lOLTktqal5+CaZvWvizmX6tJE53kOSBdT0NdjuBDV/B8qVnj5tiylVHZWqVu2lHHdO", + "H+8h2Zw+Gm4PyFhdYvvS66VrH7UY4NcbH5UYlpjaNpIpLd0qLQ2yEnv+QY6e9PSYlOHsR/n3nz++P7s0", + "1Km4xZdmBiSmrAVKfG6NVT9Qz/cEYfaafFpBTKnncaWerBcBnJbfI27z+1lb1R9cPM8Q0Ogbc8nTjnku", + "Sd8z0fTstjOToHmVg6AlfQCG8o87ZLllI29Mpx1esXfv8+sf8moFOJliH1kim5E1mdqRmNpFEOiI0lSa", + "qrX1h5qBl6XHYJue+y6idHp7BluizTDlgW5pM6rSdNjSlPvAyXYOaDtpxt0wHyOiwQaUf9+kDaiZRsBn", + "i9KqbY4OSegBEz47SnxmZDcSnOk5zxmZ2d7ZNvxjlPnIMJmhabKtYwRkubqMCiYDodjBLbEFh2WHPiIQ", + "ZkiaENihjGUoADNi6kJfyYmjMtbeqlGFdWtiPlY7qnOjrXEj/17Z1L4xotg9pKJXkdn0usHTuzmqRvAh", + "NYKhzR3V2R8hNMKXKxq1dnxc5R8M3NutybMpnzn6QdIx3W0h5UHTuwV7btioyM4VjAYCtpp1DGivre58", + "MPhUVd6psWOrMlOVicPaOtrN/IgU5uxAvn1KeY+tB4S5lbxwmPVaaR90NwnBTnG7KXlNoH2EPfYWeAtR", + "TXB9V3DdKP52WN1MHQTUP9kPPuwNpf/spdTRzdp6+gTJfxwkTxWsGl7GgPHUAgYDq3T8QWF4uZY5edht", + "MLjh4DAA3mLRx6IkZz/cYU+g+1hBd1mrtUscWCk3oCMbOxZST1XwqaX1OD+0k2v/sA/tFP+Tm7Zu1D0B", + "5Y5qx9HVsHuq10WxKuhqWN/yQzulz6663mR0l1Un5L3rD+2wchV8u97RYViqpP37/rLEyBbOyRePA92D", + "S96j9IMV+vFzfmhngOefPrRzVG84OsraQwraBo+YgWPh9VSs3nUj8lShfhqstvo+DFOndRBnB/Ge0HRb", + "AeO4as1dVea04BQ4i8yOcuUQ6Jx9ddqFm0cuOYHmbUBzUace2987DAtl6n1ooDyVprdHycOK0sN1QqU6", + "8XOC405HPsHio4LFlcKzHmMmWc2MRYjP8amQARV4c7f5bwAAAP//mrmBarCMAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/entries.go b/api/entries.go index 87374f1..18e9f40 100644 --- a/api/entries.go +++ b/api/entries.go @@ -29,6 +29,23 @@ func getSymbolAuthority() string { return symbolAuthority } +func isValidParentForType(entryType EntryType, parentEntry *db.Entry) (bool, string) { + + if entryType == "Institution" { + if parentEntry.Type == "Consortium" { + return true, "" + } + return false, "Institution parent must be of type Consortium" + } else if entryType == "Branch" { + if parentEntry.Type == "Institution" { + return true, "" + } + return false, "Branch parent must be of type Institution" + } else { + return false, "Invalid type to have parent" + } +} + func scanEntryRow(rows pgx.Rows) (Entry, int, error) { var ( id uuid.UUID @@ -482,6 +499,35 @@ func (a ApiImpl) AddEntry(ctx context.Context, request AddEntryRequestObject) (A } defer func() { _ = tx.Rollback(ctx) }() qtx := a.queries.WithTx(tx) + entryType := derefOrDefault(request.Body.Type, EntryType("Institution")) + + if entryType == "Consortium" { + if err := qtx.LockConsortiumEntryChanges(ctx); err != nil { + slog.ErrorContext(ctx, "failed to lock consortium entry changes", "error", err) + return AddEntry500TextResponse("Internal server error"), nil + } + _, err := qtx.GetConsortialEntry(ctx) + if err == nil { + return AddEntry400TextResponse("An entry of type Consortium already exists"), nil + } else if !errors.Is(err, pgx.ErrNoRows) { + slog.ErrorContext(ctx, "failed to check for consortium entry", "error", err) + return AddEntry500TextResponse("Internal server error"), nil + } + } + + if request.Body.Parent != nil { + parentEntry, err := qtx.EntryByIdForUpdate(ctx, *request.Body.Parent) + if errors.Is(err, pgx.ErrNoRows) { + return AddEntry400TextResponse("Value for Parent is not a valid Entry"), nil + } else if err != nil { + slog.ErrorContext(ctx, "failed to fetch parent entry", "error", err) + return AddEntry500TextResponse("Internal server error"), nil + } + validParent, reason := isValidParentForType(entryType, &parentEntry) + if !validParent { + return AddEntry400TextResponse("Invalid entry for parent: " + reason), nil + } + } toInsert := db.CreateEntryParams{ Name: request.Body.Name, @@ -490,7 +536,7 @@ func (a ApiImpl) AddEntry(ctx context.Context, request AddEntryRequestObject) (A PhoneNumber: request.Body.PhoneNumber, TimeZone: request.Body.TimeZone, OrganizationID: request.Body.OrganizationId, - Type: string(derefOrDefault(request.Body.Type, "Institution")), + Type: string(entryType), Parent: request.Body.Parent, LmsLocationCode: request.Body.LmsLocationCode, LenderOfLastResort: request.Body.LenderOfLastResort, @@ -656,8 +702,61 @@ func (a ApiImpl) UpdateEntry(ctx context.Context, request UpdateEntryRequestObje slog.ErrorContext(ctx, "type cannot be null") return UpdateEntry400TextResponse("'type' cannot be set to null"), nil } - origTypeEntryPatch := EntryPatchType(orig.Type) + resultingType := string(*maybeUpdateCol(&origTypeEntryPatch, request.Body.Type)) + var parentEntry db.Entry + parent := maybeUpdateCol(orig.Parent, request.Body.Parent) + if parent != nil { + if *parent == orig.ID { + return UpdateEntry400TextResponse("An entry cannot be its own parent"), nil + } + parentEntry, err = qtx.EntryByIdForUpdate(ctx, *parent) + if errors.Is(err, pgx.ErrNoRows) { + return UpdateEntry400TextResponse("Value for Parent is not a valid Entry"), nil + } else if err != nil { + slog.ErrorContext(ctx, "failed to fetch parent entry", "error", err) + return UpdateEntry500TextResponse("Internal server error"), nil + } + } + + if parent != nil { + validParent, reason := isValidParentForType(EntryType(resultingType), &parentEntry) + if !validParent { + return UpdateEntry400TextResponse("Invalid entry for parent: " + reason), nil + } + } + + if resultingType != orig.Type { + children, err := qtx.EntriesByParent(ctx, &orig.ID) + if err != nil { + slog.ErrorContext(ctx, "failed to fetch child entries", "error", err) + return UpdateEntry500TextResponse("Internal server error"), nil + } + resultingParent := orig + resultingParent.Type = resultingType + for _, child := range children { + valid, reason := isValidParentForType(EntryType(child.Type), &resultingParent) + if !valid { + return UpdateEntry400TextResponse("Entry type is invalid for existing child: " + reason), nil + } + } + } + + if resultingType == "Consortium" || orig.Type == "Consortium" { + if err := qtx.LockConsortiumEntryChanges(ctx); err != nil { + slog.ErrorContext(ctx, "failed to lock consortium entry changes", "error", err) + return UpdateEntry500TextResponse("Internal server error"), nil + } + } + if resultingType == "Consortium" && resultingType != orig.Type { + consortialEntry, err := qtx.GetConsortialEntry(ctx) + if err == nil && consortialEntry.ID != orig.ID { + return UpdateEntry400TextResponse("An entry of type Consortium already exists"), nil + } else if err != nil && !errors.Is(err, pgx.ErrNoRows) { + slog.ErrorContext(ctx, "failed to check for consortium entry", "error", err) + return UpdateEntry500TextResponse("Internal server error"), nil + } + } err = qtx.UpdateEntry(ctx, db.UpdateEntryParams{ Name: derefOrDefault(request.Body.Name, orig.Name), @@ -669,7 +768,7 @@ func (a ApiImpl) UpdateEntry(ctx context.Context, request UpdateEntryRequestObje LmsLocationCode: maybeUpdateCol(orig.LmsLocationCode, request.Body.LmsLocationCode), LenderOfLastResort: maybeUpdateCol(orig.LenderOfLastResort, request.Body.LenderOfLastResort), Hrid: maybeUpdateCol(orig.Hrid, request.Body.Hrid), - Type: string(*maybeUpdateCol(&origTypeEntryPatch, request.Body.Type)), + Type: resultingType, TimeZone: maybeUpdateCol(orig.TimeZone, request.Body.TimeZone), OrganizationID: maybeUpdateCol(orig.OrganizationID, request.Body.OrganizationId), ID: orig.ID, diff --git a/db/query.sql.go b/db/query.sql.go index 28da6a6..fdbbe63 100644 --- a/db/query.sql.go +++ b/db/query.sql.go @@ -399,6 +399,44 @@ func (q *Queries) DeleteTierById(ctx context.Context, id uuid.UUID) error { return err } +const entriesByParent = `-- name: EntriesByParent :many +SELECT id, parent, name, type, description, organization_id, contact_name, email, phone_number, lms_location_code, lender_of_last_resort, hrid, time_zone FROM entries WHERE parent = $1 +` + +func (q *Queries) EntriesByParent(ctx context.Context, parent *uuid.UUID) ([]Entry, error) { + rows, err := q.db.Query(ctx, entriesByParent, parent) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Entry + for rows.Next() { + var i Entry + if err := rows.Scan( + &i.ID, + &i.Parent, + &i.Name, + &i.Type, + &i.Description, + &i.OrganizationID, + &i.ContactName, + &i.Email, + &i.PhoneNumber, + &i.LmsLocationCode, + &i.LenderOfLastResort, + &i.Hrid, + &i.TimeZone, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const entryById = `-- name: EntryById :one SELECT id, parent, name, type, description, organization_id, contact_name, email, phone_number, lms_location_code, lender_of_last_resort, hrid, time_zone FROM entries WHERE id = $1 LIMIT 1 ` @@ -543,6 +581,31 @@ func (q *Queries) GetClosureByIdForUpdate(ctx context.Context, id uuid.UUID) (Cl return i, err } +const getConsortialEntry = `-- name: GetConsortialEntry :one +SELECT id, parent, name, type, description, organization_id, contact_name, email, phone_number, lms_location_code, lender_of_last_resort, hrid, time_zone FROM entries WHERE type = 'Consortium' LIMIT 1 +` + +func (q *Queries) GetConsortialEntry(ctx context.Context) (Entry, error) { + row := q.db.QueryRow(ctx, getConsortialEntry) + var i Entry + err := row.Scan( + &i.ID, + &i.Parent, + &i.Name, + &i.Type, + &i.Description, + &i.OrganizationID, + &i.ContactName, + &i.Email, + &i.PhoneNumber, + &i.LmsLocationCode, + &i.LenderOfLastResort, + &i.Hrid, + &i.TimeZone, + ) + return i, err +} + const getEntryNetworkById = `-- name: GetEntryNetworkById :one SELECT id, entry, network FROM entry_networks WHERE id = $1 LIMIT 1 ` @@ -1021,6 +1084,15 @@ func (q *Queries) ListTiersForEntry(ctx context.Context, entry uuid.UUID) ([]Lis return items, nil } +const lockConsortiumEntryChanges = `-- name: LockConsortiumEntryChanges :exec +SELECT pg_advisory_xact_lock(hashtextextended('directoryish:consortium-entry', 0)) +` + +func (q *Queries) LockConsortiumEntryChanges(ctx context.Context) error { + _, err := q.db.Exec(ctx, lockConsortiumEntryChanges) + return err +} + const updateClosure = `-- name: UpdateClosure :exec UPDATE closures SET diff --git a/query.sql b/query.sql index 66469f0..f0f93f9 100644 --- a/query.sql +++ b/query.sql @@ -10,6 +10,15 @@ SELECT e.* FROM entries e, symbols s WHERE e.id = s.owner AND s.authority = @aut -- name: EntryBySymbol :one SELECT e.* FROM entries e, symbols s WHERE e.id = s.owner AND s.authority = @authority AND s.symbol = @symbol LIMIT 1; +-- name: GetConsortialEntry :one +SELECT * FROM entries WHERE type = 'Consortium' LIMIT 1; + +-- name: EntriesByParent :many +SELECT * FROM entries WHERE parent = @parent; + +-- name: LockConsortiumEntryChanges :exec +SELECT pg_advisory_xact_lock(hashtextextended('directoryish:consortium-entry', 0)); + -- name: CreateEntry :one INSERT INTO entries ( name, description, contact_name, email, phone_number, time_zone, organization_id, type, parent, lms_location_code, lender_of_last_resort diff --git a/test/apifixtures/entry-new-branch-consortium-parent.post.req.json b/test/apifixtures/entry-new-branch-consortium-parent.post.req.json new file mode 100644 index 0000000..8bc0f93 --- /dev/null +++ b/test/apifixtures/entry-new-branch-consortium-parent.post.req.json @@ -0,0 +1,15 @@ +{ + "name":"New Branch", + "type":"Branch", + "symbols": [ + {"authority":"TEST", "symbol":"NWBRCH"} + ], + "parent" : "00000000-0000-0000-0000-000000000004", + "endpoints": [ + { + "name": "Primary", + "type": "ISO18626", + "address": "https://some.host.tld/path" + } + ] +} \ No newline at end of file diff --git a/test/apifixtures/entry-new-branch.post.req.json b/test/apifixtures/entry-new-branch.post.req.json new file mode 100644 index 0000000..92d6413 --- /dev/null +++ b/test/apifixtures/entry-new-branch.post.req.json @@ -0,0 +1,15 @@ +{ + "name":"New Branch", + "type":"Branch", + "symbols": [ + {"authority":"TEST", "symbol":"NWBRCH"} + ], + "parent" : "00000000-0000-0000-0000-000000000001", + "endpoints": [ + { + "name": "Primary", + "type": "ISO18626", + "address": "https://some.host.tld/path" + } + ] +} \ No newline at end of file diff --git a/test/apifixtures/entry-new-consortium.post.req.json b/test/apifixtures/entry-new-consortium.post.req.json new file mode 100644 index 0000000..fd9c81a --- /dev/null +++ b/test/apifixtures/entry-new-consortium.post.req.json @@ -0,0 +1,14 @@ +{ + "name":"New Consortium", + "type":"Consortium", + "symbols": [ + {"authority":"TEST", "symbol":"NWCONS"} + ], + "endpoints": [ + { + "name": "Primary", + "type": "ISO18626", + "address": "https://some.host.tld/path" + } + ] +} \ No newline at end of file diff --git a/test/apifixtures/entry-new-institution-bad-parent.post.req.json b/test/apifixtures/entry-new-institution-bad-parent.post.req.json new file mode 100644 index 0000000..2ab29e0 --- /dev/null +++ b/test/apifixtures/entry-new-institution-bad-parent.post.req.json @@ -0,0 +1,15 @@ +{ + "name":"New Institution", + "type":"Institution", + "symbols": [ + {"authority":"TEST", "symbol":"NWINST"} + ], + "parent" : "00000000-0000-0000-0000-000000000001", + "endpoints": [ + { + "name": "Primary", + "type": "ISO18626", + "address": "https://some.host.tld/path" + } + ] +} \ No newline at end of file diff --git a/test/apifixtures/entry-new-institution-nonexistent-parent.post.req.json b/test/apifixtures/entry-new-institution-nonexistent-parent.post.req.json new file mode 100644 index 0000000..534636e --- /dev/null +++ b/test/apifixtures/entry-new-institution-nonexistent-parent.post.req.json @@ -0,0 +1,15 @@ +{ + "name":"New Institution", + "type":"Institution", + "symbols": [ + {"authority":"TEST", "symbol":"NWINST"} + ], + "parent" : "70000000-0000-0000-0000-000000000001", + "endpoints": [ + { + "name": "Primary", + "type": "ISO18626", + "address": "https://some.host.tld/path" + } + ] +} \ No newline at end of file diff --git a/test/concurrency_test.go b/test/concurrency_test.go index 588df42..43b6344 100644 --- a/test/concurrency_test.go +++ b/test/concurrency_test.go @@ -98,4 +98,78 @@ func TestConcurrency(t *testing.T) { } } }) + + t.Run("ConcurrentConsortiumPosts", func(t *testing.T) { + resetDb() + _, err := dbpool.Exec(context.Background(), "UPDATE entries SET parent = NULL, type = 'Institution'") + if err != nil { + t.Fatalf("failed to prepare entries: %v", err) + } + + var wg sync.WaitGroup + statuses := make(chan int, 2) + for _, name := range []string{"Consortium One", "Consortium Two"} { + name := name + wg.Add(1) + go func() { + defer wg.Done() + res, _ := jsonReq(t, http.MethodPost, "/entries", `{"name":"`+name+`","type":"Consortium"}`, consortiumPermissionHeaders) + statuses <- res.StatusCode + }() + } + wg.Wait() + close(statuses) + + assertOneConsortiumWrite(t, statuses, http.StatusCreated) + }) + + t.Run("ConcurrentConsortiumPromotions", func(t *testing.T) { + resetDb() + _, err := dbpool.Exec(context.Background(), "UPDATE entries SET parent = NULL, type = 'Institution'") + if err != nil { + t.Fatalf("failed to prepare entries: %v", err) + } + + var wg sync.WaitGroup + statuses := make(chan int, 2) + for _, id := range []string{"00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"} { + id := id + wg.Add(1) + go func() { + defer wg.Done() + res, _ := jsonReq(t, http.MethodPatch, "/entries/by-id/"+id, `{"type":"Consortium"}`, consortiumPermissionHeaders) + statuses <- res.StatusCode + }() + } + wg.Wait() + close(statuses) + + assertOneConsortiumWrite(t, statuses, http.StatusNoContent) + }) +} + +func assertOneConsortiumWrite(t *testing.T, statuses <-chan int, successStatus int) { + t.Helper() + successes, rejections := 0, 0 + for status := range statuses { + switch status { + case successStatus: + successes++ + case http.StatusBadRequest: + rejections++ + default: + t.Errorf("unexpected response status: %d", status) + } + } + if successes != 1 || rejections != 1 { + t.Errorf("expected one success and one rejection, got %d successes and %d rejections", successes, rejections) + } + + var count int + if err := dbpool.QueryRow(context.Background(), "SELECT COUNT(*) FROM entries WHERE type = 'Consortium'").Scan(&count); err != nil { + t.Fatalf("failed to count consortium entries: %v", err) + } + if count != 1 { + t.Errorf("expected exactly one consortium entry, got %d", count) + } } diff --git a/test/entries_test.go b/test/entries_test.go index c3a5bce..ca635ce 100644 --- a/test/entries_test.go +++ b/test/entries_test.go @@ -310,7 +310,7 @@ func TestEntryCases(t *testing.T) { }, }, { - name: "PATCH enty type to null", + name: "PATCH entry type to null", method: http.MethodPatch, endpoint: "/entries/by-id/00000000-0000-0000-0000-000000000002", body: `{"type":null}`, @@ -449,6 +449,94 @@ func TestEntryCases(t *testing.T) { refetchFile: "entry-new-lmsconfig-incomplete.patch.refetch.json", addlHeaders: consortiumPermissionHeaders, }, + { + name: "POST entry of type Institution with Institution as parent", + method: http.MethodPost, + endpoint: "/entries", + bodyFile: "entry-new-institution-bad-parent.post.req.json", + status: http.StatusBadRequest, + addlHeaders: consortiumPermissionHeaders, + }, + { + name: "POST entry with parent and default Institution type", + method: http.MethodPost, + endpoint: "/entries", + body: `{"name":"Default Type Institution","parent":"00000000-0000-0000-0000-000000000004"}`, + status: http.StatusCreated, + addlHeaders: consortiumPermissionHeaders, + }, + { + name: "POST entry of type Institution with non-existing id as parent", + method: http.MethodPost, + endpoint: "/entries", + bodyFile: "entry-new-institution-nonexistent-parent.post.req.json", + status: http.StatusBadRequest, + addlHeaders: consortiumPermissionHeaders, + }, + { + name: "POST entry of type Consortium", + method: http.MethodPost, + endpoint: "/entries", + bodyFile: "entry-new-consortium.post.req.json", + status: http.StatusBadRequest, + addlHeaders: consortiumPermissionHeaders, + }, + { + name: "PATCH entry to type Consortium", + method: http.MethodPatch, + endpoint: "/entries/by-id/00000000-0000-0000-0000-000000000003", + body: `{"type":"Consortium"}`, + status: http.StatusBadRequest, + addlHeaders: consortiumPermissionHeaders, + }, + { + name: "PATCH entry with non-existent parent", + method: http.MethodPatch, + endpoint: "/entries/by-id/00000000-0000-0000-0000-000000000003", + body: `{"parent":"00000000-0000-0000-3000-000000000001"}`, + status: http.StatusBadRequest, + addlHeaders: consortiumPermissionHeaders, + }, + { + name: "PATCH entry with invalid parent", + method: http.MethodPatch, + endpoint: "/entries/by-id/00000000-0000-0000-0000-000000000003", + body: `{"parent":"00000000-0000-0000-0000-000000000001"}`, + status: http.StatusBadRequest, + addlHeaders: consortiumPermissionHeaders, + }, + { + name: "PATCH entry to be its own parent", + method: http.MethodPatch, + endpoint: "/entries/by-id/00000000-0000-0000-0000-000000000004", + body: `{"type":"Institution","parent":"00000000-0000-0000-0000-000000000004"}`, + status: http.StatusBadRequest, + addlHeaders: consortiumPermissionHeaders, + }, + { + name: "PATCH entry type when existing children would become invalid", + method: http.MethodPatch, + endpoint: "/entries/by-id/00000000-0000-0000-0000-000000000004", + body: `{"type":"Institution"}`, + status: http.StatusBadRequest, + addlHeaders: consortiumPermissionHeaders, + }, + { + name: "POST entry of type Branch with Institution as parent", + method: http.MethodPost, + endpoint: "/entries", + bodyFile: "entry-new-branch.post.req.json", + status: http.StatusCreated, + addlHeaders: consortiumPermissionHeaders, + }, + { + name: "POST entry of type Branch with Consortium as parent", + method: http.MethodPost, + endpoint: "/entries", + bodyFile: "entry-new-branch-consortium-parent.post.req.json", + status: http.StatusBadRequest, + addlHeaders: consortiumPermissionHeaders, + }, } testCases(t, cases) }