You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
395 lines
16 KiB
395 lines
16 KiB
/* © SRSoftware 2025 */ |
|
package de.srsoftware.umbrella.stock; |
|
|
|
import static de.srsoftware.tools.Optionals.is0; |
|
import static de.srsoftware.tools.Optionals.nullIfEmpty; |
|
import static de.srsoftware.umbrella.core.ConnectionProvider.connect; |
|
import static de.srsoftware.umbrella.core.Constants.*; |
|
import static de.srsoftware.umbrella.core.Field.ITEM; |
|
import static de.srsoftware.umbrella.core.ModuleRegistry.companyService; |
|
import static de.srsoftware.umbrella.core.ModuleRegistry.userService; |
|
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*; |
|
import static de.srsoftware.umbrella.stock.Constants.*; |
|
import static java.lang.System.Logger.Level.WARNING; |
|
import static java.text.MessageFormat.format; |
|
import static java.util.Comparator.comparing; |
|
|
|
import com.sun.net.httpserver.HttpExchange; |
|
import de.srsoftware.configuration.Configuration; |
|
import de.srsoftware.tools.Mappable; |
|
import de.srsoftware.tools.Path; |
|
import de.srsoftware.tools.SessionToken; |
|
import de.srsoftware.umbrella.core.BaseHandler; |
|
import de.srsoftware.umbrella.core.ModuleRegistry; |
|
import de.srsoftware.umbrella.core.api.Owner; |
|
import de.srsoftware.umbrella.core.api.StockService; |
|
import de.srsoftware.umbrella.core.exceptions.UmbrellaException; |
|
import de.srsoftware.umbrella.core.model.*; |
|
import de.srsoftware.umbrella.core.model.Location; |
|
import java.io.IOException; |
|
import java.util.*; |
|
import org.json.JSONObject; |
|
|
|
public class StockModule extends BaseHandler implements StockService { |
|
|
|
private final StockDb stockDb; |
|
|
|
public StockModule(Configuration config) throws UmbrellaException { |
|
super(); |
|
var dbFile = config.get(CONFIG_DATABASE).orElseThrow(() -> missingFieldException(CONFIG_DATABASE)); |
|
stockDb = new SqliteDb(connect(dbFile)); |
|
ModuleRegistry.add(this); |
|
} |
|
|
|
private boolean assigned(Owner owner, UmbrellaUser user){ |
|
owner = owner.resolve(); |
|
if (owner instanceof UmbrellaUser u && user.id() == u.id()) return true; |
|
if (owner instanceof Company comp) return companyService().membership(comp.id(),user.id()); |
|
return false; |
|
} |
|
|
|
private boolean deleteLocation(UmbrellaUser user, Location locationRef, HttpExchange ex) throws IOException { |
|
var location = locationRef.resolve(); |
|
var owner = location.owner().resolve(); |
|
if (!assigned(owner,user)) throw forbidden("You are not allowed to modify \"{0}\"",location); |
|
if (!stockDb.listItemsAt(location).isEmpty()) throw forbidden("\"{0}\" cannot be deleted, as it contains items!",location); |
|
if (!stockDb.listChildLocations(location.id()).isEmpty()) throw forbidden("\"{0}\" cannot be deleted, as it contains other locations!",location); |
|
return sendContent(ex,stockDb.delete(location)); |
|
} |
|
|
|
@Override |
|
public boolean doDelete(Path path, HttpExchange ex) throws IOException { |
|
addCors(ex); |
|
try { |
|
Optional<Token> token = SessionToken.from(ex).map(Token::of); |
|
var user = userService().loadUser(token); |
|
if (user.isEmpty()) return unauthorized(ex); |
|
var head = path.pop(); |
|
return switch (head) { |
|
case LOCATION -> { |
|
try { |
|
var location = Location.of(Long.parseLong(path.pop())); |
|
yield deleteLocation(user.get(), location, ex); |
|
} catch (NumberFormatException e){ |
|
yield super.doGet(path,ex); |
|
} |
|
} |
|
case null, default -> super.doDelete(path,ex); |
|
}; |
|
} catch (UmbrellaException e){ |
|
return send(ex,e); |
|
} |
|
} |
|
|
|
@Override |
|
public boolean doGet(Path path, HttpExchange ex) throws IOException { |
|
addCors(ex); |
|
try { |
|
Optional<Token> token = SessionToken.from(ex).map(Token::of); |
|
var user = userService().loadUser(token); |
|
if (user.isEmpty()) return unauthorized(ex); |
|
var head = path.pop(); |
|
return switch (head) { |
|
case COMPANY -> { |
|
try { |
|
var company = companyService().get(Long.parseLong(path.pop())); |
|
yield getItem(user.get(),company,path,ex); |
|
} catch (NumberFormatException e){ |
|
yield super.doGet(path,ex); |
|
} |
|
} |
|
case LOCATION -> { |
|
try { |
|
var location = Location.of(Long.parseLong(path.pop())); |
|
yield getLocationEntities(location, ex); |
|
} catch (Exception e){ |
|
yield super.doGet(path,ex); |
|
} |
|
} |
|
case LOCATIONS -> getLocations(path,user.get(),ex); |
|
case PROPERTIES -> getProperties(ex); |
|
case USER -> { |
|
try { |
|
var userId = Long.parseLong(path.pop()); |
|
if (userId != user.get().id()) throw forbidden("You are not allowed to access items of another user!"); |
|
yield getItem(user.get(),user.get(),path,ex); |
|
} catch (NumberFormatException e){ |
|
yield super.doGet(path,ex); |
|
} |
|
} |
|
case null, default -> super.doGet(path,ex); |
|
}; |
|
} catch (UmbrellaException e){ |
|
return send(ex,e); |
|
} |
|
} |
|
|
|
@Override |
|
public boolean doPatch(Path path, HttpExchange ex) throws IOException { |
|
addCors(ex); |
|
try { |
|
Optional<Token> token = SessionToken.from(ex).map(Token::of); |
|
var user = userService().loadUser(token); |
|
if (user.isEmpty()) return unauthorized(ex); |
|
return switch (path.pop()){ |
|
case MOVE_ITEM -> patchMoveItem(user.get(), path,ex); |
|
case LOCATION -> { |
|
try { |
|
var id = Long.parseLong(path.pop()); |
|
yield patchLocation(id, user.get(), ex); |
|
} catch (NumberFormatException nfe){ |
|
yield super.doPatch(path,ex); |
|
} |
|
|
|
} |
|
case null -> patchItem(user.get(),ex); |
|
default -> super.doPatch(path,ex); |
|
}; |
|
} catch (UmbrellaException e){ |
|
return send(ex,e); |
|
} |
|
} |
|
|
|
@Override |
|
public boolean doPost(Path path, HttpExchange ex) throws IOException { |
|
addCors(ex); |
|
try { |
|
Optional<Token> token = SessionToken.from(ex).map(Token::of); |
|
var user = userService().loadUser(token); |
|
if (user.isEmpty()) return unauthorized(ex); |
|
var head = path.pop(); |
|
return switch (head) { |
|
case ITEM -> postItem(user.get(), ex); |
|
case LOCATION -> postLocation(user.get(),ex); |
|
case PROPERTY -> postProperty(user.get(),ex); |
|
case null, default -> super.doPost(path,ex); |
|
}; |
|
} catch (UmbrellaException e){ |
|
return send(ex,e); |
|
} |
|
} |
|
|
|
private boolean getChildLocations(UmbrellaUser user, long parentId, HttpExchange ex) throws IOException { |
|
LOG.log(WARNING,"No security check implemented for {0}.getChildLocations(user, parentId, ex)!",getClass().getSimpleName()); // TODO check, that user is allowed to request that location |
|
return sendContent(ex, stockDb.listChildLocations(parentId).stream().sorted(comparing(l -> l.name().toLowerCase())).map(DbLocation::toMap)); |
|
} |
|
|
|
private boolean getItem(UmbrellaUser user, Owner owner, Path path, HttpExchange ex) throws IOException { |
|
if (!assigned(owner,user)) throw forbidden("You are not allowed to access items of {0}",owner); |
|
return switch (path.pop()){ |
|
case ITEM -> { |
|
try { |
|
var itemId = Long.parseLong(path.pop()); |
|
var item = stockDb.loadItem(owner.dbCode(),itemId); |
|
yield getLocationEntities(item.location(),ex); |
|
} catch (NumberFormatException e) { |
|
yield super.doGet(path,ex); |
|
} |
|
} |
|
case null, default -> super.doGet(path,ex); |
|
}; |
|
} |
|
|
|
private boolean getLocationEntities(Location location, HttpExchange ex) throws IOException { |
|
var items = stockDb.listItemsAt(location).stream().map(Item::toMap).toList(); |
|
var owner = location.resolve().owner(); |
|
List<Long> userIds = switch (owner.type()){ |
|
case COMPANY -> companyService().getMembers(owner.id()).stream().map(UmbrellaUser::id).toList(); |
|
case USER -> List.of(owner.id()); |
|
case null, default -> throw unprocessable("Unprocessable owner type: {0}",owner.type()); |
|
}; |
|
var pathToLocation = stockDb.pathToLocation(location); |
|
return sendContent(ex,Map.of(ITEMS,items,USERS,userIds,PATH,pathToLocation,LOCATION,location.resolve().toMap())); |
|
} |
|
|
|
private boolean getLocations(Path path, UmbrellaUser user, HttpExchange ex) throws IOException { |
|
var head = path.pop(); |
|
return switch (head){ |
|
case BELOW -> { |
|
try { |
|
var id = Long.parseLong(path.pop()); |
|
yield getChildLocations(user, id, ex); |
|
} catch (Exception e){ |
|
yield super.doGet(path,ex); |
|
} |
|
|
|
} |
|
case OF_USER -> getUserLocations(user,ex); |
|
case null, default -> super.doGet(path,ex); |
|
}; |
|
} |
|
|
|
private boolean getProperties(HttpExchange ex) throws IOException { |
|
return sendContent(ex,stockDb.listProperties().stream().map(Property::toMap).toList()); |
|
} |
|
|
|
private boolean getUserLocations(UmbrellaUser user, HttpExchange ex) throws IOException { |
|
var result = new ArrayList<Object>(); |
|
var userLocations = stockDb.listUserLocations(user); |
|
result.add(Map.of( |
|
PARENT, Map.of(USER, user.id()), |
|
NAME,user.name(), |
|
LOCATIONS,userLocations.stream().map(DbLocation::toMap).toList())); |
|
|
|
var companies = companyService().listCompaniesOf(user); |
|
companies.values().stream().sorted(comparing(a -> a.name().toLowerCase())).forEach(company -> { |
|
var locations = stockDb.listCompanyLocations(company); |
|
result.add(Map.of( |
|
PARENT, Map.of(COMPANY, company.id()), |
|
NAME,company.name(), |
|
LOCATIONS,locations.stream().sorted(comparing(a -> a.name().toLowerCase())).map(DbLocation::toMap).toList())); |
|
|
|
}); |
|
return sendContent(ex, result); |
|
} |
|
|
|
@Override |
|
public DbLocation loadLocation(long locationId) { |
|
return stockDb.loadLocation(locationId); |
|
} |
|
|
|
private boolean patchItem(UmbrellaUser user, HttpExchange ex) throws IOException { |
|
var json = json(ex); |
|
if (!(json.get(ID) instanceof Number id)) throw missingFieldException(ID); |
|
json.remove(ID); |
|
|
|
var item = stockDb.loadItem(id.longValue()); |
|
item.patch(json); |
|
return sendContent(ex,stockDb.save(item)); |
|
} |
|
|
|
private boolean patchMoveItem(UmbrellaUser user, Path path, HttpExchange ex) throws IOException { |
|
var json = json(ex); |
|
if (!(json.get(ITEM) instanceof Number itemId)) throw missingFieldException(ITEM); |
|
if (!(json.get(TARGET) instanceof Number locationId)) throw missingFieldException(TARGET); |
|
var item = stockDb.loadItem(itemId.longValue()); |
|
|
|
var itemOwner = item.owner().resolve(); |
|
if (!assigned(itemOwner,user)) throw forbidden("You are not allowed to alter the location of \"{0}\"!",item.name()); |
|
|
|
var target = stockDb.loadLocation(locationId.longValue()); |
|
var locOwner = target.resolve().owner().resolve(); |
|
if (!assigned(locOwner,user)) throw forbidden("You are not allowed to modify \"{0}\"!",target.name()); |
|
|
|
if (!locOwner.equals(itemOwner)) throw unprocessable("You may not move items from one owner ({0}) to another ({1})",itemOwner,locOwner); |
|
stockDb.save(item.location(target)); |
|
|
|
return sendContent(ex,item); |
|
} |
|
|
|
private boolean patchLocation(long locationId, UmbrellaUser user, HttpExchange ex) throws IOException { |
|
var json = json(ex); |
|
var location = stockDb.loadLocation(locationId); |
|
var owner = location.owner().resolve(); |
|
if (!assigned(owner,user)) throw forbidden("You are not allowed to edit \"{0}\"!",location.name()); |
|
|
|
if (json.has(PARENT_LOCATION_ID) && json.get(PARENT_LOCATION_ID) instanceof Number parentId){ |
|
if (parentId.longValue() != 0L) { |
|
var target = stockDb.loadLocation(parentId.longValue()); |
|
if (target.id() == location.id()) throw forbidden("Location must not be it`s own parent!"); |
|
var current = target; |
|
while (current != null){ |
|
if (current.id() == locationId) throw forbidden("Location cannot be contained in itself!"); |
|
current = is0(current.parent()) ? null : stockDb.loadLocation(current.parent()); |
|
} |
|
var targetOwner = target.owner().resolve(); |
|
if (!assigned(targetOwner, user)) throw forbidden("You are not allowed to edit \"{0}\"!", target.name()); |
|
if (!targetOwner.equals(owner)) throw unprocessable("You may not move locations from one owner ({0}) to another ({1})", owner, targetOwner); |
|
LOG.log(WARNING,"Not checking, if location is moved to on of its own children!"); |
|
} |
|
} |
|
|
|
location = stockDb.save(location.patch(json)); |
|
return sendContent(ex,location); |
|
} |
|
|
|
private boolean postItem(UmbrellaUser user, HttpExchange ex) throws IOException { |
|
var json = json(ex); |
|
if (!json.has(NAME) || !(json.get(NAME) instanceof String name)) throw missingFieldException(NAME); |
|
if (!json.has(CODE) || !(json.get(CODE) instanceof String code)) throw missingFieldException(CODE); |
|
if (!json.has(LOCATION) || !(json.get(LOCATION) instanceof JSONObject locationData)) throw missingFieldException(LOCATION); |
|
var location = stockDb.loadLocation(locationData.getLong(ID)); |
|
var owner = location.owner().resolve(); |
|
if (!assigned(owner,user)) throw forbidden("You are not allowed to add items to {0}!",location); |
|
var number = stockDb.nextItemNumberFor(owner); |
|
var newItem = new Item(0,owner,number,location,code,name); |
|
return sendContent(ex,stockDb.save(newItem)); |
|
} |
|
|
|
private boolean postLocation(UmbrellaUser user, HttpExchange ex) throws IOException { |
|
var json = json(ex); |
|
if (!(json.get(NAME) instanceof String name)) throw missingFieldException(NAME); |
|
if (!(json.get(PARENT) instanceof JSONObject parentData)) throw missingFieldException(PARENT); |
|
var key = parentData.keySet().stream().findFirst().orElseThrow(() -> missingFieldException(PARENT)); |
|
if (!(parentData.get(key) instanceof Number id)) throw missingFieldException(key); |
|
Location parent; |
|
Owner owner; |
|
switch (key){ |
|
case COMPANY: |
|
owner = companyService().get(id.longValue()); |
|
parent = null; |
|
break; |
|
case USER: |
|
owner = userService().loadUser(id.longValue()); |
|
parent = null; |
|
break; |
|
case LOCATION: |
|
parent = stockDb.loadLocation(id.longValue()); |
|
owner = parent.resolve().owner().resolve(); |
|
break; |
|
default: throw unprocessable("Unknown parent object: {0} → {1}",key,id); |
|
}; |
|
var loc = new DbLocation(0,owner,parent == null?null:parent.id(),name,null); |
|
return sendContent(ex,stockDb.save(loc)); |
|
} |
|
|
|
private boolean postProperty(UmbrellaUser user, HttpExchange ex) throws IOException { |
|
var json = json(ex); |
|
if (!(json.get(ITEM) instanceof JSONObject itemData)) throw missingFieldException(ITEM); |
|
if (!(itemData.get(ID) instanceof Number itemId)) throw missingFieldException(ID); |
|
if (!(json.get("add_prop") instanceof JSONObject propData)) throw missingFieldException("add_prop"); |
|
if (!propData.has(VALUE)) throw missingFieldException(VALUE); |
|
var value = propData.get(VALUE); |
|
if (value == null) throw missingFieldException(VALUE); |
|
|
|
Property property = null; |
|
if (propData.get("existing_prop_id") instanceof Number existingPropId && existingPropId.longValue() != 0L){ |
|
property = stockDb.setProperty(itemId.longValue(),existingPropId.longValue(),value); |
|
} else { |
|
if (!(propData.get("new_prop") instanceof JSONObject newProp)) throw unprocessable("data must contain either add_prop.existing_prop_id or add_prop.new_prop!"); |
|
if (!(newProp.get(NAME) instanceof String name) || name.isBlank()) throw unprocessable("data.add_prop.new_prop does not contain name!"); |
|
var unit = newProp.get(UNIT) instanceof String u ? nullIfEmpty(u) : null; |
|
property = stockDb.addNewProperty(itemId.longValue(),name,value,unit); |
|
} |
|
return sendContent(ex,property); |
|
} |
|
|
|
private Mappable toOwner(JSONObject owner) { |
|
var keys = owner.keySet(); |
|
if (keys.size() != 1) throw unprocessable("{0} expected to have only one child!",OWNER); |
|
String key = new ArrayList<>(keys).getFirst(); |
|
return switch (key) { |
|
case COMPANY -> companyService().get(owner.getLong(key)); |
|
case USER -> userService().loadUser(owner.getLong(key)); |
|
default -> throw invalidFieldException(format("Single child of {0}", OWNER), format("either {0} or {1}", COMPANY, USER)); |
|
}; |
|
|
|
} |
|
|
|
private long toOwnerId(JSONObject owner) { |
|
var keys = owner.keySet(); |
|
if (keys.size() != 1) throw unprocessable("{0} expected to have only one child!",OWNER); |
|
String key = new ArrayList<>(keys).getFirst(); |
|
return switch (key) { |
|
case COMPANY -> -owner.getLong(key); |
|
case USER -> owner.getLong(key); |
|
default -> throw invalidFieldException(format("Single child of {0}", OWNER), format("either {0} or {1}", COMPANY, USER)); |
|
}; |
|
|
|
} |
|
|
|
@Override |
|
public Collection<Object> redefineMe(long company_id) { |
|
return List.of(); |
|
} |
|
}
|
|
|