diff --git a/README.md b/README.md index e8aa6f3d8..770469207 100644 --- a/README.md +++ b/README.md @@ -172,3 +172,74 @@ For additional details, please refer to the blog post [Hello DCO, Goodbye CLA: S ## License The Spring PetClinic sample application is released under version 2.0 of the [Apache License](https://www.apache.org/licenses/LICENSE-2.0). + +# PetClinic Feature Flags +- This project adds feature flags to the PetClinic app so that we can control the features to certain users. + +# How it works: +- Enable/Disable: + Each feature can be enabled/disabled for the users based on if they are whitelisted or blacklisted. There is also a global disability which shuts down the specified feature until enabled. +- Whitelist: + Only users listed in the whitelist can access the feature specified. +- Blacklist: + Users listed in the blacklist will be blocked from the feature even if the feature is enabled. +- Rollout Percentage: + You can release a feature gradually instead of a global release. For example, if rollout_percentage = 50, roughly 50% of users get access (deterministic by user ID). + +# Example: + | Feature Key | Enabled | Blacklist | Whitelist | Rollout % | + | ------------ | ------- | --------- | --------- | --------- | + | ADD_PET | true | 4 | 1 | 100 | + | OWNER_SEARCH | true | - | - | 50 | + + Owner with id 4 is blacklisted i.e. they cannot add pets. + Owner with id 1 is whiteliste i.e. they can addpets. + The rollout percentage of OWNER_SEARCH key is 50% i.e. roughly half the users can search for owners. + +# Using Feature Flags in Code: + @FeatureFlagEnabled("ADD_PET") + @GetMapping("/owners/{ownerId}/pets/new") + public String newPetForm(@PathVariable int ownerId) { + return "pets/createPetForm"; + } + + The Aspect intercepts the method and checks if the feature i.e. ADD_PET is: + 1. Enabled + 2. If user is whitelisted/blacklisted + 3. Rollout percentage + If access is denied, the user gets HTTP 403 Forbidden. + +# Database Setup: +- Tables + CREATE TABLE feature_flags ( + id INT AUTO_INCREMENT PRIMARY KEY, + flag_key VARCHAR(100) UNIQUE, + is_enabled BOOLEAN, + globally_disabled BOOLEAN, + description VARCHAR(255), + rollout_percentage INT + ); + + CREATE TABLE feature_flag_blacklist ( + feature_flag_id INT, + owner_id INT + ); + + CREATE TABLE feature_flag_whitelist ( + feature_flag_id INT, + owner_id INT + ); + +- Example Data + INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description, rollout_percentage) + VALUES ('ADD_PET', true, false, 'Allows adding pets', 100); + + INSERT INTO feature_flag_blacklist (feature_flag_id, owner_id) VALUES (1, 4); + INSERT INTO feature_flag_whitelist (feature_flag_id, owner_id) VALUES (1, 1); + +# Why Feature Flags? +- Roll out features gradually. +- Access control i.e. privilege to users. +- Feature blocking whenever needed i.e. internal errors or data corruption. +- Avoid frequent code redeployment for feature changes. + diff --git a/src/main/java/org/springframework/samples/petclinic/model/FeatureFlag.java b/src/main/java/org/springframework/samples/petclinic/model/FeatureFlag.java new file mode 100644 index 000000000..81a1819b9 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/model/FeatureFlag.java @@ -0,0 +1,135 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.samples.petclinic.model; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +@Entity +@Table(name = "feature_flags") +public class FeatureFlag extends BaseEntity { + + @Column(name = "flag_key", unique = true) + @NotBlank + private String key; + + @Column(name = "is_enabled") + private boolean isEnabled; + + @Column(name = "globally_disabled") + private boolean globallyDisabled; + + @Column(name = "rollout_percentage") + private Integer rolloutPercentage; + + @Column(name = "description") + private String description; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "feature_flag_blacklist", joinColumns = @JoinColumn(name = "feature_flag_id")) + @JsonIgnore + @Column(name = "owner_id") + private List blacklistedUsers = new ArrayList<>(); + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "feature_flag_whitelist", joinColumns = @JoinColumn(name = "feature_flag_id")) + @JsonIgnore + @Column(name = "owner_id") + private List whitelistedUsers = new ArrayList<>(); + + public boolean isAllowedForOwner(Integer ownerId) { + if (globallyDisabled) { + return false; + } + + if (ownerId != null && blacklistedUsers.contains(ownerId)) { + return false; + } + + if (ownerId != null && whitelistedUsers.contains(ownerId)) { + return true; + } + + if (rolloutPercentage != null && ownerId != null) { + int bucket = Math.abs(ownerId.hashCode() % 100); + return bucket < rolloutPercentage; + } + return isEnabled; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public boolean isEnabled() { + return isEnabled; + } + + public void setEnabled(boolean enabled) { + this.isEnabled = enabled; + } + + public boolean isGloballyDisabled() { + return globallyDisabled; + } + + public void setGloballyDisabled(boolean globallyDisabled) { + this.globallyDisabled = globallyDisabled; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @JsonIgnore + public List getBlacklistedUsers() { + return blacklistedUsers; + } + + public void setBlacklistedUsers(List blacklistedUsers) { + this.blacklistedUsers = blacklistedUsers; + } + + @JsonIgnore + public List getWhitelistedUsers() { + return whitelistedUsers; + } + + public void setWhitelistedUsers(List whitelistedUsers) { + this.whitelistedUsers = whitelistedUsers; + } + +} diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java index 199ca3611..0f59e24e7 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java @@ -20,6 +20,7 @@ import java.util.Objects; import java.util.Optional; import org.springframework.data.domain.Page; +import org.springframework.samples.petclinic.system.FeatureFlagEnabled; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Controller; @@ -69,11 +70,13 @@ class OwnerController { + ". Please ensure the ID is correct " + "and the owner exists in the database.")); } + @FeatureFlagEnabled("OWNER_ADD") @GetMapping("/owners/new") public String initCreationForm() { return VIEWS_OWNER_CREATE_OR_UPDATE_FORM; } + @FeatureFlagEnabled("OWNER_ADD") @PostMapping("/owners/new") public String processCreationForm(@Valid Owner owner, BindingResult result, RedirectAttributes redirectAttributes) { if (result.hasErrors()) { @@ -92,6 +95,7 @@ class OwnerController { } @GetMapping("/owners") + @FeatureFlagEnabled("OWNER_SEARCH") public String processFindForm(@RequestParam(defaultValue = "1") int page, Owner owner, BindingResult result, Model model) { // allow parameterless GET request for /owners to return all records @@ -133,11 +137,13 @@ class OwnerController { return owners.findByLastNameStartingWith(lastname, pageable); } + @FeatureFlagEnabled("OWNER_EDIT") @GetMapping("/owners/{ownerId}/edit") public String initUpdateOwnerForm() { return VIEWS_OWNER_CREATE_OR_UPDATE_FORM; } + @FeatureFlagEnabled("OWNER_EDIT") @PostMapping("/owners/{ownerId}/edit") public String processUpdateOwnerForm(@Valid Owner owner, BindingResult result, @PathVariable("ownerId") int ownerId, RedirectAttributes redirectAttributes) { diff --git a/src/main/java/org/springframework/samples/petclinic/owner/PetController.java b/src/main/java/org/springframework/samples/petclinic/owner/PetController.java index 8398e4f13..34f6af82e 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/PetController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/PetController.java @@ -21,6 +21,7 @@ import java.util.Objects; import java.util.Optional; import org.springframework.stereotype.Controller; +import org.springframework.samples.petclinic.system.FeatureFlagEnabled; import org.springframework.ui.ModelMap; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -95,6 +96,7 @@ class PetController { dataBinder.setValidator(new PetValidator()); } + @FeatureFlagEnabled("ADD_PET") @GetMapping("/pets/new") public String initCreationForm(Owner owner, ModelMap model) { Pet pet = new Pet(); @@ -103,6 +105,7 @@ class PetController { } @PostMapping("/pets/new") + @FeatureFlagEnabled("ADD_PET") public String processCreationForm(Owner owner, @Valid Pet pet, BindingResult result, RedirectAttributes redirectAttributes) { @@ -124,11 +127,13 @@ class PetController { return "redirect:/owners/{ownerId}"; } + @FeatureFlagEnabled("EDIT_PET") @GetMapping("/pets/{petId}/edit") public String initUpdateForm() { return VIEWS_PETS_CREATE_OR_UPDATE_FORM; } + @FeatureFlagEnabled("EDIT_PET") @PostMapping("/pets/{petId}/edit") public String processUpdateForm(Owner owner, @Valid Pet pet, BindingResult result, RedirectAttributes redirectAttributes) { diff --git a/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java b/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java index cc3e3ce1a..56481c469 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java @@ -19,6 +19,7 @@ import java.util.Map; import java.util.Optional; import org.springframework.stereotype.Controller; +import org.springframework.samples.petclinic.system.FeatureFlagEnabled; import org.springframework.validation.BindingResult; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.GetMapping; @@ -89,6 +90,7 @@ class VisitController { // Spring MVC calls method loadPetWithVisit(...) before processNewVisitForm is // called @PostMapping("/owners/{ownerId}/pets/{petId}/visits/new") + @FeatureFlagEnabled("ADD_VISIT") public String processNewVisitForm(@ModelAttribute Owner owner, @PathVariable int petId, @Valid Visit visit, BindingResult result, RedirectAttributes redirectAttributes) { if (result.hasErrors()) { diff --git a/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagAspect.java b/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagAspect.java new file mode 100644 index 000000000..56e65d46f --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagAspect.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.system; + +import jakarta.servlet.http.HttpServletRequest; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Aspect +@Component +public class FeatureFlagAspect { + + private final FeatureFlagHelper featureFlagHelper; + + private final HttpServletRequest request; + + public FeatureFlagAspect(FeatureFlagHelper featureFlagHelper, HttpServletRequest request) { + this.featureFlagHelper = featureFlagHelper; + this.request = request; + } + + @Around("@annotation(featureFlagEnabled)") + public Object checkFeatureFlag(ProceedingJoinPoint joinPoint, FeatureFlagEnabled featureFlagEnabled) + throws Throwable { + String featureFlagKey = featureFlagEnabled.value(); + Integer ownerId = extractOwnerIdFromPath(); + boolean enabled = featureFlagHelper.isEnabled(featureFlagKey, ownerId); + if (!enabled) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Feature '" + featureFlagKey + "' is disabled"); + } + return joinPoint.proceed(); + } + + private Integer extractOwnerIdFromPath() { + String uri = request.getRequestURI(); + String[] parts = uri.split("/"); + for (int i = 0; i < parts.length - 1; i++) { + if ("owners".equals(parts[i])) { + try { + return Integer.parseInt(parts[i + 1]); + } + catch (NumberFormatException e) { + return null; + } + } + } + return null; + } + +} diff --git a/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagController.java b/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagController.java new file mode 100644 index 000000000..a4a81e95d --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagController.java @@ -0,0 +1,77 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.system; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.samples.petclinic.model.FeatureFlag; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/feature-flags") +public class FeatureFlagController { + + private final FeatureFlagRepository featureFlagRepository; + + public FeatureFlagController(FeatureFlagRepository featureFlagRepository) { + this.featureFlagRepository = featureFlagRepository; + } + + @GetMapping + public ResponseEntity> getAllFlags() { + List flags = (List) featureFlagRepository.findAll(); + return ResponseEntity.ok(flags); + } + + @PostMapping + public ResponseEntity createFlag(@RequestBody FeatureFlag featureFlag) { + FeatureFlag saved = featureFlagRepository.save(featureFlag); + return ResponseEntity.status(HttpStatus.CREATED).body(saved); + } + + @PutMapping("/{id}") + public ResponseEntity updateFlag(@PathVariable Integer id, @RequestBody FeatureFlag featureFlag) { + if (!featureFlagRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + featureFlag.setId(id); + FeatureFlag updated = featureFlagRepository.save(featureFlag); + return ResponseEntity.ok(updated); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteFlag(@PathVariable Integer id) { + if (!featureFlagRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + featureFlagRepository.findById(id).ifPresent(flag -> { + flag.getBlacklistedUsers().clear(); + flag.getWhitelistedUsers().clear(); + featureFlagRepository.delete(flag); + }); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagEnabled.java b/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagEnabled.java new file mode 100644 index 000000000..c9e1ad0e5 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagEnabled.java @@ -0,0 +1,39 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.system; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to restrict access to a method based on a feature flag. + * + * If the feature flag is disabled or globally disabled, the method will not be executed. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface FeatureFlagEnabled { + + /** + * The key of the feature flag to check. + */ + String value(); + +} diff --git a/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagHelper.java b/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagHelper.java new file mode 100644 index 000000000..060f5803b --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagHelper.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.system; + +import org.springframework.samples.petclinic.model.FeatureFlag; +import org.springframework.stereotype.Component; + +@Component +public class FeatureFlagHelper { + + private final FeatureFlagRepository featureFlagRepository; + + public FeatureFlagHelper(FeatureFlagRepository featureFlagRepository) { + this.featureFlagRepository = featureFlagRepository; + } + + public boolean isEnabled(String flagKey, Integer userId) { + FeatureFlag flag = featureFlagRepository.findByKey(flagKey); + + if (flag == null) { + return false; // fail-safe default + } + + return flag.isAllowedForOwner(userId); + } + +} diff --git a/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagRepository.java b/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagRepository.java new file mode 100644 index 000000000..a35d4f6d0 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/system/FeatureFlagRepository.java @@ -0,0 +1,25 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.system; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.samples.petclinic.model.FeatureFlag; + +public interface FeatureFlagRepository extends JpaRepository { + + FeatureFlag findByKey(String key); + +} diff --git a/src/main/java/org/springframework/samples/petclinic/vet/VetController.java b/src/main/java/org/springframework/samples/petclinic/vet/VetController.java index 89ad9bc41..59e066171 100644 --- a/src/main/java/org/springframework/samples/petclinic/vet/VetController.java +++ b/src/main/java/org/springframework/samples/petclinic/vet/VetController.java @@ -18,6 +18,7 @@ package org.springframework.samples.petclinic.vet; import java.util.List; import org.springframework.data.domain.Page; +import org.springframework.samples.petclinic.system.FeatureFlagEnabled; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Controller; @@ -41,6 +42,7 @@ class VetController { this.vetRepository = vetRepository; } + @FeatureFlagEnabled("VIEW_VETS") @GetMapping("/vets.html") public String showVetList(@RequestParam(defaultValue = "1") int page, Model model) { // Here we are returning an object of type 'Vets' rather than a collection of Vet @@ -66,6 +68,7 @@ class VetController { return vetRepository.findAll(pageable); } + @FeatureFlagEnabled("VIEW_VETS") @GetMapping({ "/vets" }) public @ResponseBody Vets showResourcesVetList() { // Here we are returning an object of type 'Vets' rather than a collection of Vet diff --git a/src/main/resources/db/h2/data.sql b/src/main/resources/db/h2/data.sql index f232b1361..9b2ade2d6 100644 --- a/src/main/resources/db/h2/data.sql +++ b/src/main/resources/db/h2/data.sql @@ -51,3 +51,18 @@ INSERT INTO visits VALUES (default, 7, '2013-01-01', 'rabies shot'); INSERT INTO visits VALUES (default, 8, '2013-01-02', 'rabies shot'); INSERT INTO visits VALUES (default, 8, '2013-01-03', 'neutered'); INSERT INTO visits VALUES (default, 7, '2013-01-04', 'spayed'); + +INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('ADD_PET', false, false, 'Allows adding new pets'); +INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('EDIT_PET', true, false, 'Allows editing existing pets'); +INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('ADD_VISIT', true, false, 'Allows adding new visits'); +INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('OWNER_SEARCH', true, false, 'Allows searching for owners'); +INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('OWNER_EDIT', true, false, 'Allows editing owner information'); +INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('OWNER_ADD', true, false, 'Allows adding new owners'); +INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('VIEW_VETS', true, false, 'Allows viewing vet information'); + +-- id 5 can't edit owners +INSERT INTO feature_flag_blacklist (feature_flag_id, owner_id) VALUES (1, 5); + +-- only id 1 can add pets and owners +INSERT INTO feature_flag_whitelist (feature_flag_id, owner_id) VALUES (1, 1); +INSERT INTO feature_flag_whitelist (feature_flag_id, owner_id) VALUES (6, 1); diff --git a/src/main/resources/db/h2/schema.sql b/src/main/resources/db/h2/schema.sql index 4a6c322cb..c5f0e968e 100644 --- a/src/main/resources/db/h2/schema.sql +++ b/src/main/resources/db/h2/schema.sql @@ -5,6 +5,8 @@ DROP TABLE visits IF EXISTS; DROP TABLE pets IF EXISTS; DROP TABLE types IF EXISTS; DROP TABLE owners IF EXISTS; +DROP TABLE feature_flags IF EXISTS; +DROP TABLE feature_flag_blacklist IF EXISTS; CREATE TABLE vets ( @@ -62,3 +64,36 @@ CREATE TABLE visits ( ); ALTER TABLE visits ADD CONSTRAINT fk_visits_pets FOREIGN KEY (pet_id) REFERENCES pets (id); CREATE INDEX visits_pet_id ON visits (pet_id); + +CREATE TABLE feature_flags ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + flag_key VARCHAR(100) NOT NULL, + is_enabled BOOLEAN, + globally_disabled BOOLEAN, + rollout_percentage INTEGER, + description VARCHAR(255) +); + +CREATE INDEX idx_feature_flags_key ON feature_flags(flag_key); + +CREATE TABLE feature_flag_blacklist ( + feature_flag_id INTEGER NOT NULL, + owner_id INTEGER NOT NULL, + CONSTRAINT fk_feature_blacklist_flag + FOREIGN KEY (feature_flag_id) + REFERENCES feature_flags(id), + CONSTRAINT fk_blacklist_owner + FOREIGN KEY (owner_id) + REFERENCES owners(id) +); + +CREATE TABLE feature_flag_whitelist ( + feature_flag_id INTEGER NOT NULL, + owner_id INTEGER NOT NULL, + CONSTRAINT fk_feature_whitelist_flag + FOREIGN KEY (feature_flag_id) + REFERENCES feature_flags(id), + CONSTRAINT fk_whitelist_owner + FOREIGN KEY (owner_id) + REFERENCES owners(id) +);