package org.karmaexchange.dao;
import static com.google.common.base.Preconditions.checkState;
import static org.karmaexchange.util.OfyService.ofy;
import static org.karmaexchange.util.UserService.getCurrentUserKey;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import javax.annotation.Nullable;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.XmlTransient;
import org.apache.commons.validator.routines.EmailValidator;
import org.karmaexchange.auth.GlobalUid;
import org.karmaexchange.auth.GlobalUidMapping;
import org.karmaexchange.auth.GlobalUidType;
import org.karmaexchange.auth.AuthProvider.UserInfo;
import org.karmaexchange.dao.Organization.Role;
import org.karmaexchange.resources.msg.AuthorizationErrorInfo;
import org.karmaexchange.resources.msg.BaseDaoView;
import org.karmaexchange.resources.msg.ErrorResponseMsg;
import org.karmaexchange.resources.msg.ValidationErrorInfo;
import org.karmaexchange.resources.msg.ErrorResponseMsg.ErrorInfo;
import org.karmaexchange.resources.msg.ValidationErrorInfo.ValidationError;
import org.karmaexchange.resources.msg.ValidationErrorInfo.ValidationErrorType;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import com.google.api.client.util.Sets;
import com.google.appengine.api.blobstore.BlobKey;
import com.google.common.base.Predicate;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.googlecode.objectify.Key;
import com.googlecode.objectify.VoidWork;
import com.googlecode.objectify.annotation.Cache;
import com.googlecode.objectify.annotation.Entity;
import com.googlecode.objectify.annotation.Ignore;
import com.googlecode.objectify.annotation.Index;
@XmlRootElement
@Entity
// TODO(avaliani): re-eval this caching strategy once OAuth caching is re-worked.
@Cache
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper=true)
@ToString(callSuper=true)
public final class User extends IdBaseDao<User> implements BaseDaoView<User> {
private static final int MAX_ATTENDANCE_HISTORY = 10;
@Index
private String firstName;
@Index
private String lastName;
@Index
private String searchableFullName;
@Index
private String nickName;
private Gender gender;
private AgeRange ageRange;
private ImageRef profileImage;
private List<RegisteredEmail> registeredEmails = Lists.newArrayList();
private String phoneNumber;
private Address address;
// NOTE: Embedded list is safe since EmergencyContact has no embedded objects.
private List<EmergencyContact> emergencyContacts = Lists.newArrayList();
private String about;
@Index
private List<CauseType> causes = Lists.newArrayList();
// Skipping interests for now.
// Facebook has a detailed + categorized breakdown of interests.
@Index
private long karmaPoints;
private KarmaGoal karmaGoal;
private List<AttendanceRecord> eventAttendanceHistory = Lists.newArrayList();
@Ignore
private Double eventAttendanceHistoryPct;
private IndexedAggregateRating eventOrganizerRating;
private EventSearch lastEventSearch;
private boolean emailOptOut;
// A list of all orgs the user has participated in events with.
// TODO(avaliani): update this with map reduce. Currently it is initialized at
// creation only with the source org.
// TODO(avaliani): update this every time a user successfully registers with an organization.
private Set<KeyWrapper<Organization>> eventOrgs = Sets.newHashSet();
private List<OrganizationPrefs> orgPrefs = Lists.newArrayList();
// TODO(avaliani): profileSecurityPrefs
// NOTE: Embedded list is safe since OrganizationMembership has been modified to avoid
// encountering the objectify serialization bug (issue #127).
private List<OrganizationMembership> organizationMemberships = Lists.newArrayList();
// TODO(avaliani): cleanup post demo.
private List<BadgeSummary> badges = Lists.newArrayList();
public static User create() {
User user = new User();
user.setModificationInfo(ModificationInfo.create());
return user;
}
@Override
protected void preProcessInsert() {
super.preProcessInsert();
initSearchableFullName();
// TODO(avaliani): ProfileImage is valuable for testing. Eventually null
// this out.
// profileImage = null;
eventOrganizerRating = IndexedAggregateRating.create();
karmaPoints = 0;
if (karmaGoal == null) {
karmaGoal = new KarmaGoal();
}
eventAttendanceHistory = Lists.newArrayList();
organizationMemberships = Lists.newArrayList();
validateUser();
}
@Override
protected void postProcessInsert() {
super.postProcessInsert();
}
@Override
protected void processUpdate(User oldUser) {
super.processUpdate(oldUser);
// Some fields can not be manipulated by updating the user.
initSearchableFullName();
// Some fields are explicitly updated.
profileImage = oldUser.profileImage;
eventOrganizerRating = oldUser.eventOrganizerRating;
karmaPoints = oldUser.karmaPoints;
eventAttendanceHistory = oldUser.eventAttendanceHistory;
organizationMemberships = oldUser.organizationMemberships;
// Temporarily prevent the user email field from being updated. We want to ensure
// all emails are verified.
registeredEmails = oldUser.registeredEmails;
validateUser();
}
@Override
public void processLoad() {
super.processLoad();
updateEventAttendanceHistoryPct();
}
private void validateUser() {
List<ValidationError> validationErrors = Lists.newArrayList();
boolean primaryEmailFound = false;
for (RegisteredEmail registeredEmail : registeredEmails) {
validationErrors.addAll(registeredEmail.validate(this));
if (registeredEmail.isPrimary()) {
if (primaryEmailFound) {
// Multiple primary emails.
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_INVALID,
"registeredEmails.isPrimary"));
}
primaryEmailFound = true;
}
}
if (!registeredEmails.isEmpty() && !primaryEmailFound) {
// Emails registered but no primary email.
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_INVALID,
"registeredEmails.isPrimary"));
}
if (!validationErrors.isEmpty()) {
throw ValidationErrorInfo.createException(validationErrors);
}
}
private void initSearchableFullName() {
if ((searchableFullName == null) && (firstName != null) && (lastName != null)) {
searchableFullName = firstName + " " + lastName;
}
if (searchableFullName != null) {
searchableFullName = searchableFullName.toLowerCase();
}
}
@Override
protected void processDelete() {
// TODO(avaliani):
// - revoke OAuth credentials. This way the user account won't be re-created
// automatically.
// - remove the user from all events that the user is a participant of.
if (profileImage != null) {
BaseDao.delete(KeyWrapper.toKey(profileImage.getRef()));
profileImage = null;
}
}
@Override
protected Permission evalPermission() {
if (Key.create(this).equals(getCurrentUserKey())) {
return Permission.ALL;
} else {
return Permission.READ;
}
}
public static Key<User> upsertNewUser(UserInfo userInfo) {
User user = userInfo.getUser();
checkState(!user.isKeyComplete(), "new user can not have complete key");
// Lookup the user by email to see if one already exists.
Key<User> existingUserKey = findUser(getNewUserEmail(user));
Key<User> newUserKey = null;
if (existingUserKey == null) {
BaseDao.upsert(user);
// TODO(avaliani): users should be in an orphaned state until they are attached
if (userInfo.getProfileImage() != null) {
updateProfileImage(Key.create(user), userInfo.getProfileImage().getProvider(),
userInfo.getProfileImage().getUrl());
}
// Attempt to create an email to user mapping for the new user
CreateEmailMappingForNewUserTxn createEmailMappingTxn =
new CreateEmailMappingForNewUserTxn(user);
ofy().transact(createEmailMappingTxn);
if (createEmailMappingTxn.getExistingUser() == null) {
newUserKey = Key.create(user);
} else {
// Another user may have been created in parallel. Delete the user we just persisted.
BaseDao.delete(Key.create(user));
existingUserKey = createEmailMappingTxn.getExistingUser();
}
}
if (newUserKey == null) {
ofy().transact(new UpdateUnitializedFieldsTxn(existingUserKey, user));
return existingUserKey;
} else {
return newUserKey;
}
}
private static String getNewUserEmail(User user) {
// Handling multiple emails complicates the email to user lookup. It's something we
// don't have to deal with now since every new user is only associated with one email
// address.
checkState(user.getRegisteredEmails().size() == 1,
"new user num emails (" + user.getRegisteredEmails().size() + ") != 1");
return user.getRegisteredEmails().get(0).getEmail();
}
private static Key<User> findUser(String email) {
GlobalUid guid =
new GlobalUid(GlobalUidType.EMAIL, email);
GlobalUidMapping mapping =
GlobalUidMapping.load(guid);
return (mapping != null) ? mapping.getUserKey() : null;
}
@Data
@EqualsAndHashCode(callSuper=false)
private static class CreateEmailMappingForNewUserTxn extends VoidWork {
private final User newUser;
private Key<User> existingUser;
public void vrun() {
GlobalUid guid = new GlobalUid(GlobalUidType.EMAIL, getNewUserEmail(newUser));
GlobalUidMapping mapping = GlobalUidMapping.load(guid);
if (mapping == null) {
mapping = new GlobalUidMapping(guid, Key.create(newUser));
ofy().save().entity(mapping).now();
} else {
existingUser = mapping.getUserKey();
}
}
}
@Data
@EqualsAndHashCode(callSuper=false)
private static class UpdateUnitializedFieldsTxn extends VoidWork {
private final Key<User> existingUserKey;
private final User newUser;
public void vrun() {
User user = ofy().load().key(existingUserKey).now();
if (user == null) {
throw ErrorResponseMsg.createException("existing user not found",
ErrorInfo.Type.BACKEND_SERVICE_FAILURE);
}
if (user.firstName == null) {
user.firstName = newUser.firstName;
}
if (user.lastName == null) {
user.lastName = newUser.lastName;
}
if (user.address == null) {
user.address = newUser.address;
}
if (user.gender == null) {
user.gender = newUser.gender;
}
if (user.ageRange == null) {
user.ageRange = newUser.ageRange;
}
user.emailOptOut = newUser.emailOptOut;
if (user.orgPrefs == null) {
user.orgPrefs = newUser.orgPrefs;
}
BaseDao.partialUpdate(user);
}
}
private void updateProfileImage(@Nullable Image profileImage) {
this.profileImage = (profileImage == null) ? null : ImageRef.create(profileImage);
}
public static void updateProfileImage(Key<User> userKey, BlobKey blobKey) {
ofy().transact(new UpdateProfileImageTxn(userKey, ImageProviderType.BLOBSTORE, null, blobKey));
}
public static void updateProfileImage(Key<User> userKey, ImageProviderType imageProviderType,
String imageUrl) {
ofy().transact(new UpdateProfileImageTxn(userKey, imageProviderType, imageUrl, null));
}
public static void deleteProfileImage(Key<User> userKey) {
ofy().transact(new UpdateProfileImageTxn(userKey, null, null, null));
}
@Data
@EqualsAndHashCode(callSuper=false)
private static class UpdateProfileImageTxn extends VoidWork {
private final Key<User> userKey;
private final ImageProviderType imageProviderType;
private final String imageUrl;
private final BlobKey blobKey;
public void vrun() {
User user = ofy().load().key(userKey).now();
if (user == null) {
throw ErrorResponseMsg.createException("user not found", ErrorInfo.Type.BAD_REQUEST);
}
if (!user.permission.canEdit()) {
throw ErrorResponseMsg.createException(
"insufficient privileges to edit user", ErrorInfo.Type.BAD_REQUEST);
}
setProfileImage(user, imageProviderType, imageUrl, blobKey);
BaseDao.partialUpdate(user);
}
}
private static void setProfileImage(User user, ImageProviderType imageProviderType,
String imageUrl, BlobKey blobKey) {
Key<User> userKey = Key.create(user);
Key<Image> existingImageKey = null;
if (user.profileImage != null) {
existingImageKey = KeyWrapper.toKey(user.profileImage.getRef());
BaseDao.delete(existingImageKey);
}
Image newProfileImage;
if (imageProviderType == ImageProviderType.FACEBOOK) {
newProfileImage = Image.createAndPersist(userKey, imageProviderType, imageUrl);
} else if (imageProviderType == ImageProviderType.BLOBSTORE) {
newProfileImage = Image.createAndPersist(userKey, blobKey, null);
} else {
newProfileImage = null;
}
user.updateProfileImage(newProfileImage);
if (existingImageKey != null) {
ImageRef.updateRefs(existingImageKey,
(newProfileImage == null) ? null : Key.create(newProfileImage));
}
}
@Data
@NoArgsConstructor
public static final class OrganizationMembership {
private KeyWrapper<Organization> organization;
@Nullable
private Organization.Role role;
@Nullable
private Organization.Role requestedRole;
// Added to enable querying
@Index
@Nullable
private NullableKeyWrapper<Organization> organizationMember =
NullableKeyWrapper.create();
@Index
@Nullable
private NullableKeyWrapper<Organization> organizationMemberWithAdminRole =
NullableKeyWrapper.create();
@Index
@Nullable
private NullableKeyWrapper<Organization> organizationMemberWithOrganizerRole =
NullableKeyWrapper.create();
@Index
@Nullable
private NullableKeyWrapper<Organization> organizationPendingMembershipRequest =
NullableKeyWrapper.create();
public OrganizationMembership(Key<Organization> orgKey,
@Nullable Organization.Role grantedRole, @Nullable Organization.Role requestedRole) {
organization = KeyWrapper.create(orgKey);
this.role = grantedRole;
this.requestedRole = requestedRole;
if (grantedRole != null) {
organizationMember = NullableKeyWrapper.create(orgKey);
if (grantedRole == Role.ADMIN) {
organizationMemberWithAdminRole = NullableKeyWrapper.create(orgKey);
} else if (grantedRole == Role.ORGANIZER) {
organizationMemberWithOrganizerRole = NullableKeyWrapper.create(orgKey);
}
}
if (requestedRole != null) {
organizationPendingMembershipRequest = NullableKeyWrapper.create(orgKey);
}
}
public static Predicate<OrganizationMembership> userPredicate(final Key<Organization> orgKey) {
return new Predicate<OrganizationMembership>() {
@Override
public boolean apply(@Nullable OrganizationMembership input) {
return KeyWrapper.toKey(input.organization).equals(orgKey);
}
};
}
}
public boolean hasOrgMembership(Key<Organization> org, Organization.Role role) {
OrganizationMembership membership = tryFindOrganizationMembership(org);
return (membership != null) && (membership.role != null) &&
membership.role.hasEqualOrMoreCapabilities(role);
}
@Nullable
public OrganizationMembership tryFindOrganizationMembership(Key<Organization> orgKey) {
return Iterables.tryFind(organizationMemberships, OrganizationMembership.userPredicate(orgKey))
.orNull();
}
public static void updateMembership(Key<User> userToUpdateKey, Key<Organization> organizationKey,
@Nullable Organization.Role role) {
Organization org = ofy().load().key(organizationKey).now();
if (org == null) {
throw ErrorResponseMsg.createException("org not found", ErrorInfo.Type.BAD_REQUEST);
}
ofy().transact(new UpdateMembershipTxn(
userToUpdateKey, org, org.isCurrentUserOrgAdmin(), role));
}
@Data
@EqualsAndHashCode(callSuper=false)
public static class UpdateMembershipTxn extends VoidWork {
private final Key<User> userToUpdateKey;
private final Organization organization;
private final boolean currentUserIsOrgAdmin;
@Nullable
private final Organization.Role reqRole;
public void vrun() {
User user = ofy().load().key(userToUpdateKey).now();
if (user == null) {
throw ErrorResponseMsg.createException("user not found", ErrorInfo.Type.BAD_REQUEST);
}
OrganizationMembership existingMembership =
user.tryFindOrganizationMembership(Key.create(organization));
RequestStatus membershipStatus = null;
if (currentUserIsOrgAdmin) {
// TODO(avaliani): If the current user is an org admin and the target user is not a member
// of the org and has not requested to be a member of the org, we should validate
// via email that the user wants to join the org.
membershipStatus = RequestStatus.ACCEPTED;
} else {
if (reqRole == null) {
// Delete membership.
if (!getCurrentUserKey().equals(userToUpdateKey)) {
throw AuthorizationErrorInfo.createException(userToUpdateKey);
}
} else {
// Add / modify role.
membershipStatus = RequestStatus.PENDING;
if ((existingMembership != null) &&
(existingMembership.role != null) &&
(existingMembership.role.hasEqualOrMoreCapabilities(reqRole))) {
membershipStatus = RequestStatus.ACCEPTED;
} else {
for (RegisteredEmail registeredEmail : user.registeredEmails) {
if (organization.canAutoGrantMembership(registeredEmail.email, reqRole)) {
membershipStatus = RequestStatus.ACCEPTED;
break;
}
}
}
}
}
// First remove any existing membership.
if (existingMembership != null) {
user.organizationMemberships.remove(existingMembership);
}
// Then add the new role if any.
if (reqRole != null) {
OrganizationMembership membershipToUpsert;
if (membershipStatus == RequestStatus.ACCEPTED) {
membershipToUpsert = new OrganizationMembership(Key.create(organization), reqRole, null);
} else {
membershipToUpsert = new OrganizationMembership(Key.create(organization),
(existingMembership == null) ? null : existingMembership.role, reqRole);
}
user.organizationMemberships.add(membershipToUpsert);
}
// Persist the changes.
BaseDao.partialUpdate(user);
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class RegisteredEmail {
private String email;
private boolean primary;
// TODO(avaliani): private boolean verified
public List<ValidationError> validate(User user) {
List<ValidationError> validationErrors = Lists.newArrayList();
if (!EmailValidator.getInstance().isValid(email)) {
validationErrors.add(new ResourceValidationError(
user, ValidationErrorType.RESOURCE_FIELD_VALUE_INVALID,
"registeredEmails[email=\"" + email + "\"]"));
}
return validationErrors;
}
}
@XmlTransient
public String getPrimaryEmail() {
for (RegisteredEmail registeredEmail : getRegisteredEmails()) {
if (registeredEmail.isPrimary()) {
return registeredEmail.getEmail();
}
}
return null;
}
public void removeFromEventAttendanceHistory(Key<Event> eventKey) {
Iterables.removeIf(eventAttendanceHistory, AttendanceRecord.eventPredicate(eventKey));
}
public void addToAttendanceHistory(AttendanceRecord newRec) {
eventAttendanceHistory.add(newRec);
Collections.sort(eventAttendanceHistory, AttendanceRecord.EventStartTimeComparator.INSTANCE);
if (eventAttendanceHistory.size() > MAX_ATTENDANCE_HISTORY) {
eventAttendanceHistory.subList(MAX_ATTENDANCE_HISTORY, eventAttendanceHistory.size()).clear();
}
}
public void updateEventAttendanceHistoryPct() {
int eventsAttended = 0;
for (AttendanceRecord rec : eventAttendanceHistory) {
if (rec.isAttended()) {
eventsAttended++;
}
}
eventAttendanceHistoryPct = ((double) eventsAttended) / eventAttendanceHistory.size() * 100;
}
@Override
public User getDao() {
return this;
}
@Data
public static class KarmaGoal {
private static final long DEFAULT_MONTHLY_GOAL = 1 * 60;
private long monthlyGoal = DEFAULT_MONTHLY_GOAL;
}
}