package org.karmaexchange.dao;
import static java.lang.String.format;
import static org.karmaexchange.util.OfyService.ofy;
import static org.karmaexchange.util.UserService.getCurrentUserKey;
import static com.google.common.base.CharMatcher.WHITESPACE;
import static com.google.common.base.Preconditions.checkState;
import java.util.Collection;
import java.util.Date;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.XmlTransient;
import org.karmaexchange.dao.AssociatedOrganization.Association;
import org.karmaexchange.dao.Organization.Role;
import org.karmaexchange.resources.msg.ErrorResponseMsg;
import org.karmaexchange.resources.msg.ErrorResponseMsg.ErrorInfo;
import org.karmaexchange.resources.msg.ValidationErrorInfo;
import org.karmaexchange.resources.msg.ValidationErrorInfo.ValidationError;
import org.karmaexchange.resources.msg.ValidationErrorInfo.ValidationErrorType;
import org.karmaexchange.task.ProcessRatingsServlet;
import org.karmaexchange.util.BoundedHashSet;
import org.karmaexchange.util.SearchUtil;
import org.karmaexchange.util.UserService;
import org.karmaexchange.util.derived.SourceEventSyncUtil;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
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.Entity;
import com.googlecode.objectify.annotation.Ignore;
import com.googlecode.objectify.annotation.Index;
// TODO(avaliani):
// - Fix EventSearchView for images once we revamp it.
@XmlRootElement
@Entity
@Data
@EqualsAndHashCode(callSuper=true)
@ToString(callSuper=true)
public final class Event extends BaseEvent<Event> {
/*
* DESIGN DETAILS
*
* Event permissions
* -----------------
* The current permissions scheme is that there is one organization for each event. And that any
* organizer or admin for the organization can edit the event. This simple model handles the
* 99% usage scenario.
*/
public static final int MAX_CACHED_PARTICIPANTS = 10;
/* Each event search token results in an index write. Put a reasonable limit on it. */
public static final int MAX_SEARCH_TOKENS = 100;
/*
* TODO(avaliani):
* - look at volunteer match schema
* - compare this to Meetup, OneBrick, Golden Gate athletic club, etc.
*/
private String shiftDescription;
private String specialInstructions; // See flash volunteer.
@Index
private List<CauseType> causes = Lists.newArrayList();
@Ignore
private Status status;
private AlbumRef album;
// private Image primaryImage;
// BUG: This embedded list is not safe since Image has embedded objects that can be
// optionally null. See objectify serialization bug (issue #127).
// TODO(avaliani): fix this embedded list.
// private List<Image> allImages = Lists.newArrayList();
// TODO(avaliani): Organizations can co-host events.
@Index
private KeyWrapper<Organization> organization;
private List<AssociatedOrganization> associatedOrganizations = Lists.newArrayList();
// Can not be explicitly set. Automatically managed.
@Ignore
private List<KeyWrapper<User>> organizers = Lists.newArrayList();
// Can not be explicitly set. Automatically managed.
@Ignore
private RegistrationInfo registrationInfo;
// The maxRegistration limit only applies to participants. The limit does not include organizers.
private int maxRegistrations;
// Can not be explicitly set. Automatically managed.
@Ignore
private List<KeyWrapper<User>> registeredUsers = Lists.newArrayList();
// Can not be explicitly set. Automatically managed.
@Ignore
private List<KeyWrapper<User>> waitListedUsers = Lists.newArrayList();
// NOTE: Embedded list is safe since EventParticipant has embedded objects that are always
// non-null.
private List<EventParticipant> participants = Lists.newArrayList();
// We need a consolidated list because pagination does not support OR queries.
// NOTE: We can try adding a conditional index parameter to EventParticipant in the future.
// Need to make sure that there are no objectify serialization / deserialization bugs with
// that model.
@Index
private List<KeyWrapper<User>> indexedParticipants = Lists.newArrayList();
// Can not be set. Automatically managed. Only includes organizers and registered users. Wait
// listed users images are skipped.
private List<CachedEventParticipant> cachedParticipants = Lists.newArrayList();
private IndexedAggregateRating rating;
private DerivedRatingTracker derivedRatings;
@Index
private List<String> searchableTokens;
@Index
private boolean completionProcessed;
private CompletionTaskTracker completionTasks;
/*
* If this is false and the event is complete then the organizer should be asked to update
* the attendance info and write an event thank you note.
*/
private boolean organizerProcessedCompletion;
private String impactSummary;
private List<SuitableForType> suitableForTypes = Lists.newArrayList();
private KeyWrapper<Waiver> waiver;
private String locationInformationHtml;
private String externalRegistrationUrl;
private String externalRegistrationDetailsHtml;
private SourceEventInfo sourceEventInfo;
public enum RegistrationInfo {
ORGANIZER,
REGISTERED,
REGISTERED_NO_SHOW,
WAIT_LISTED,
CAN_REGISTER,
CAN_WAIT_LIST,
FULL,
REGISTRATION_CLOSED
}
public enum Status {
UPCOMING,
IN_PROGRESS,
COMPLETED
}
public enum ParticipantType {
ORGANIZER,
REGISTERED,
REGISTERED_NO_SHOW,
WAIT_LISTED;
public boolean countAsAttended() {
return (this == ORGANIZER) || (this == REGISTERED);
}
public boolean countAsNoShow() {
return (this == REGISTERED_NO_SHOW);
}
}
private enum MutationType {
INSERT,
UPDATE,
DELETE
}
public void setOrganizers(List<KeyWrapper<User>> ignored) {
// No-op it.
}
public void setRegisteredUsers(List<KeyWrapper<User>> ignored) {
// No-op it.
}
public void setWaitListedUsers(List<KeyWrapper<User>> ignored) {
// No-op it.
}
public void setCachedParticipantImages(List<CachedEventParticipant> ignored) {
// No-op it.
}
public void setRegistrationInfo(RegistrationInfo ignored) {
// No-op it.
}
public static String getParticipantPropertyName() {
return "indexedParticipants.key";
}
@Override
protected void preProcessInsert() {
super.preProcessInsert();
if (title != null) {
title = WHITESPACE.trimFrom(title);
}
for (EventParticipant participant : participants) {
if (participant.type == ParticipantType.REGISTERED_NO_SHOW) {
throw ErrorResponseMsg.createException(
"only events that have completed can have no show participants",
ErrorInfo.Type.BAD_REQUEST);
}
}
initParticipantLists();
// Add the current user as an organizer if there are no organizers registered.
if (organizers.isEmpty() && UserService.isCurrentUserLoggedIn()) {
Iterables.removeIf(participants, EventParticipant.userPredicate(getCurrentUserKey()));
participants.add(
new EventParticipant(getCurrentUserKey(), ParticipantType.ORGANIZER));
initParticipantLists();
}
processParticipants();
processSuitableFor();
validateEvent();
rating = IndexedAggregateRating.create();
initDerivedRatings();
// The list of associated organizations is consumed by initSearchableTokens(), so the
// associated organizations must be set prior to invoking initSearchableTokens().
initAssociatedOrganizations();
initSearchableTokens();
completionProcessed = false;
completionTasks = null;
}
@Override
protected void processUpdate(Event prevObj) {
super.processUpdate(prevObj);
if (title != null) {
title = WHITESPACE.trimFrom(title);
}
// Rating is independently and transactionally updated.
rating = prevObj.rating;
derivedRatings = prevObj.derivedRatings;
// Participants is independently and transactionally updated.
participants = Lists.newArrayList(prevObj.participants);
processParticipants();
processSuitableFor();
validateEvent();
// The list of associated organizations is consumed by initSearchableTokens(), so the
// associated organizations must be set prior to invoking initSearchableTokens().
initAssociatedOrganizations();
initSearchableTokens();
completionProcessed = prevObj.completionProcessed;
completionTasks = prevObj.completionTasks;
// Do event validation that is specific to event updates.
validateEventUpdate(prevObj);
}
private void processParticipantMutation(EventParticipant updatedParticipant,
MutationType mutationType) {
processParticipantMutation(ImmutableList.of(updatedParticipant), mutationType);
}
private void processParticipantMutation(Collection<EventParticipant> mutatedParticipants,
MutationType mutationType) {
processParticipants();
for (EventParticipant participant : mutatedParticipants) {
derivedRatings.processParticipantMutation(this, participant, mutationType);
if (completionTasks != null) {
completionTasks.processParticipantMutation(this, participant, mutationType);
updateCompletionProcessed();
}
}
validateEvent();
}
private void processRatingUpdate() {
derivedRatings.processRatingUpdate(this);
}
private void processPendingRating() {
derivedRatings.processPendingRating(this);
}
private boolean hasPendingRatings() {
return derivedRatings.hasPendingRatings();
}
private void processCompletionTasks() {
// Note that we don't check the time to see if it's okay to process completion tasks.
// Instead we trust that the caller has checked the time. This handles cases where there
// is clock skew and the completion task was aborted and restarted on a different machine.
if (completionTasks == null) {
// This is the first time completion tasks are being processed. On the first pass the
// event object state must be cleaned up.
removeWaitListedUsers();
// After the state is cleaned up we process the remaining event completion tasks.
completionTasks = new CompletionTaskTracker(this);
}
completionTasks.processPendingTask(this);
updateCompletionProcessed();
}
private void updateCompletionProcessed() {
completionProcessed = !completionTasks.tasksPending();
}
private void removeWaitListedUsers() {
List<EventParticipant> participantsRemoved = Lists.newArrayList();
Iterator<EventParticipant> participantIter = participants.iterator();
while (participantIter.hasNext()) {
EventParticipant participant = participantIter.next();
if (participant.getType() == ParticipantType.WAIT_LISTED) {
participantIter.remove();
participantsRemoved.add(participant);
}
}
processParticipantMutation(participantsRemoved, MutationType.DELETE);
}
private void processParticipants() {
initParticipantLists();
processWaitList();
updateCachedParticipantImages();
}
private void processSuitableFor() {
if (!suitableForTypes.isEmpty()) {
// Eliminate any duplicates.
EnumSet<SuitableForType> suitableForSet = EnumSet.copyOf(suitableForTypes);
suitableForTypes = Lists.newArrayList(suitableForSet);
}
}
@Override
public void processLoad() {
// initParticipantLists() must be called prior to processLoad so that updatePermissions can
// use the participant lists to calculate the permissions.
initParticipantLists();
super.processLoad();
initStatus();
// Ordering is important. Registration info depends on status.
updateRegistrationInfo();
}
private void initStatus() {
status = computeStatus(this);
}
private static Status computeStatus(Event event) {
if (event.completionTasks != null) {
return Status.COMPLETED;
}
Date now = new Date();
if (now.before(event.startTime)) {
return Status.UPCOMING;
} else if (now.before(event.endTime)) {
return Status.IN_PROGRESS;
} else {
return Status.COMPLETED;
}
}
private void initDerivedRatings() {
derivedRatings = new DerivedRatingTracker(this);
}
private void initAssociatedOrganizations() {
associatedOrganizations =
getExplicitOrgAssociations();
Key<Organization> eventOwnerKey = KeyWrapper.toKey(organization);
for (Organization org : Organization.getOrgAndAncestorOrgs(eventOwnerKey)) {
Association association =
Key.create(org).equals(eventOwnerKey) ? Association.EVENT_OWNER :
Association.EVENT_OWNER_ANCESTOR;
associatedOrganizations.add(
new AssociatedOrganization(org, association));
}
}
private List<AssociatedOrganization> getExplicitOrgAssociations() {
List<AssociatedOrganization> explicitOrgAssocs =
Lists.newArrayList();
for (AssociatedOrganization org : associatedOrganizations) {
if ( (org.getAssociation() != AssociatedOrganization.Association.EVENT_OWNER) &&
(org.getAssociation() != AssociatedOrganization.Association.EVENT_OWNER_ANCESTOR) ) {
explicitOrgAssocs.add(org);
}
}
return explicitOrgAssocs;
}
@XmlTransient
public AssociatedOrganization getSponsoringOrg() {
AssociatedOrganization eventOwnerOrg = null;
for (AssociatedOrganization org : associatedOrganizations) {
if (org.getAssociation() == AssociatedOrganization.Association.EVENT_OWNER) {
eventOwnerOrg = org;
}
if (org.getAssociation() == AssociatedOrganization.Association.EVENT_SPONSOR) {
return org;
}
}
// If there is no specified event sponsor then the owning organization is the event
// sponsor.
return eventOwnerOrg;
}
private void initSearchableTokens() {
BoundedHashSet<String> searchableTokensSet = BoundedHashSet.create(MAX_SEARCH_TOKENS);
Key<Organization> primaryOrgKey = KeyWrapper.toKey(organization);
// Throw an exception if we can't add the primary org token to the searchableTokensSet.
searchableTokensSet.add(
Organization.getPrimaryOrgSearchToken(primaryOrgKey));
for (AssociatedOrganization assocOrg : associatedOrganizations) {
Key<Organization> assocOrgKey =
KeyWrapper.toKey(assocOrg);
// Throw an exception if we can't add the org token to the searchableTokensSet.
searchableTokensSet.add(
Organization.getAssociatedOrgsSearchToken(assocOrgKey));
}
for (SuitableForType suitableForType : suitableForTypes) {
searchableTokensSet.addIfSpace(suitableForType.getTag());
}
// The lowest priority tokens should be added to the end of the searchableContent. Once the
// token limit is hit the remaining tokens will be discarded.
StringBuilder searchableContent = new StringBuilder();
searchableContent.append(title);
searchableContent.append(' ');
for (CauseType causeType : causes) {
// We add causes both as tags and text strings to be parsed since keywords like
// homeless and animals are good for non-tag based keyword search.
searchableContent.append(causeType.getDescription());
searchableContent.append(' ');
searchableTokensSet.addIfSpace(causeType.getSearchToken());
}
if ((location != null) && (location.getTitle() != null)) {
searchableContent.append(location.getTitle());
searchableContent.append(' ');
}
searchableContent.append(description);
SearchUtil.addSearchableTokens(searchableTokensSet, searchableContent.toString(),
EnumSet.of(SearchUtil.ParseOptions.EXCLUDE_RESERVED_TOKENS));
searchableTokens = Lists.newArrayList(searchableTokensSet);
}
private void initParticipantLists() {
organizers = Lists.newArrayList();
registeredUsers = Lists.newArrayList();
waitListedUsers = Lists.newArrayList();;
indexedParticipants = Lists.newArrayList();;
for (EventParticipant participant : participants) {
switch (participant.getType()) {
case ORGANIZER:
organizers.add(participant.getUser());
indexedParticipants.add(participant.getUser());
break;
case REGISTERED:
registeredUsers.add(participant.getUser());
indexedParticipants.add(participant.getUser());
break;
case REGISTERED_NO_SHOW:
// Do nothing.
break;
case WAIT_LISTED:
waitListedUsers.add(participant.getUser());
indexedParticipants.add(participant.getUser());
break;
default:
checkState(false, "unknown participant type: " + participant.getType());
}
}
}
@Nullable
private EventParticipant tryFindParticipant(Key<User> userKey) {
return Iterables.tryFind(participants, EventParticipant.userPredicate(userKey)).orNull();
}
public List<KeyWrapper<User>> getParticipants(ParticipantType participantType) {
List<KeyWrapper<User>> participantSubset = Lists.newArrayList();
for (EventParticipant participant : participants) {
if (participant.type == participantType) {
participantSubset.add(participant.user);
}
}
return participantSubset;
}
private void validateEvent() {
List<ValidationError> validationErrors = Lists.newArrayList();
// Managed events must have a duration
if ( (startTime != null) && (endTime != null) && !startTime.before(endTime)) {
validationErrors.add(new MultiFieldResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_MUST_BE_GT_SPECIFIED_FIELD,
"endTime", "startTime"));
}
if (organization == null) {
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_REQUIRED, "organization"));
} else {
if (organizers.isEmpty()) {
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_REQUIRED, "organizers"));
Map<Key<User>, User> organizerEntities =
ofy().transactionless().load().keys(KeyWrapper.toKeys(organizers));
for (Map.Entry<Key<User>, User> organizerEntry : organizerEntities.entrySet()) {
if (organizerEntry.getValue() == null) {
validationErrors.add(new ListValueValidationError(
this, ValidationErrorType.RESOURCE_FIELD_LIST_VALUE_INVALID_VALUE, "organizers",
organizerEntry.getKey().getString()));
} else if (!organizerEntry.getValue().hasOrgMembership(
KeyWrapper.toKey(organization), Role.ORGANIZER)) {
validationErrors.add(new ListValueValidationError(
this, ValidationErrorType.RESOURCE_FIELD_LIST_VALUE_INVALID_PERMISSIONS, "organizers",
organizerEntry.getKey().getString()));
}
}
}
}
for (AssociatedOrganization assocOrg : associatedOrganizations) {
ValidationError validationError =
assocOrg.validate(this, "associatedOrganizations");
if (validationError != null) {
validationErrors.add(validationError);
}
}
if (!validationErrors.isEmpty()) {
throw ValidationErrorInfo.createException(validationErrors);
}
}
private void validateEventUpdate(Event prevObj) {
List<ValidationError> validationErrors = Lists.newArrayList();
// Check the prev object's state since some of the fields of the current object can be
// manipulated in an update.
if (computeStatus(prevObj) == Status.COMPLETED) {
if (!startTime.equals(prevObj.startTime)) {
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_UNMODIFIABLE, "startTime"));
}
if (!endTime.equals(prevObj.endTime)) {
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_UNMODIFIABLE, "endTime"));
}
// To simplify completion task processing, we don't allow organizations to be modifiable
// after event completion.
if (!organization.equals(prevObj.organization)) {
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_UNMODIFIABLE, "organization"));
}
}
if (!validationErrors.isEmpty()) {
throw ValidationErrorInfo.createException(validationErrors);
}
}
private void processWaitList() {
if ((registeredUsers.size() < maxRegistrations) &&
!waitListedUsers.isEmpty()) {
int numSpots = maxRegistrations - registeredUsers.size();
List<KeyWrapper<User>> usersToRegister =
waitListedUsers.subList(0, Math.min(numSpots, waitListedUsers.size()));
for (KeyWrapper<User> userToRegister : usersToRegister) {
EventParticipant participant = Iterables.find(participants,
EventParticipant.userPredicate(KeyWrapper.toKey(userToRegister)));
participant.setType(ParticipantType.REGISTERED);
}
initParticipantLists();
}
}
private void updateCachedParticipantImages() {
// Prune any deleted or wait listed participants.
Iterator<CachedEventParticipant> cachedParticipantsIter = cachedParticipants.iterator();
while (cachedParticipantsIter.hasNext()) {
CachedEventParticipant cachedParticipant = cachedParticipantsIter.next();
Key<User> userKey = KeyWrapper.toKey(cachedParticipant);
EventParticipant participant = tryFindParticipant(userKey);
if ((participant == null) ||
((participant.getType() != ParticipantType.ORGANIZER) &&
(participant.getType() != ParticipantType.REGISTERED))) {
cachedParticipantsIter.remove();
}
}
// Add images if there is room.
int numParticipantsToCache = getNumAttending() - cachedParticipants.size();
numParticipantsToCache = Math.min(numParticipantsToCache,
MAX_CACHED_PARTICIPANTS - cachedParticipants.size());
if (numParticipantsToCache > 0) {
List<Key<User>> usersToFetch = Lists.newArrayList();
for (KeyWrapper<User> participantKey :
getAttendingParticipants(MAX_CACHED_PARTICIPANTS)) {
if (!Iterables.any(cachedParticipants,
CachedEventParticipant.userPredicate(participantKey))) {
usersToFetch.add(KeyWrapper.toKey(participantKey));
numParticipantsToCache--;
if (numParticipantsToCache == 0) {
break;
}
}
}
Collection<User> participantsToCache =
ofy().transactionless().load().keys(usersToFetch).values();
for (User participantToCache : participantsToCache) {
if (participantToCache != null) {
cachedParticipants.add(new CachedEventParticipant(participantToCache));
}
}
}
}
private List<KeyWrapper<User>> getAttendingParticipants(int limit) {
List<KeyWrapper<User>> attendingParticipants = Lists.newArrayList();
for (KeyWrapper<User> organizer : organizers) {
attendingParticipants.add(organizer);
limit--;
if (limit == 0) {
break;
}
}
if (limit > 0) {
for (KeyWrapper<User> registeredUser : registeredUsers) {
attendingParticipants.add(registeredUser);
limit--;
if (limit == 0) {
break;
}
}
}
return attendingParticipants;
}
public int getNumAttending() {
return organizers.size() + registeredUsers.size();
}
public void setNumAttending(int ignore) {
// No-op.
}
private void updateRegistrationInfo() {
registrationInfo = getRegistrationInfo(getCurrentUserKey());
}
public RegistrationInfo getRegistrationInfo(Key<User> userKey) {
EventParticipant participant = tryFindParticipant(userKey);
if (participant == null) {
if (status == Status.COMPLETED) {
return RegistrationInfo.REGISTRATION_CLOSED;
} else {
if (registeredUsers.size() < maxRegistrations) {
return RegistrationInfo.CAN_REGISTER;
} else {
return RegistrationInfo.CAN_WAIT_LIST;
}
}
// } else if (waitListedUsers.size() < maxWaitingList) {
// return RegistrationInfo.CAN_WAIT_LIST;
// } else {
// return RegistrationInfo.FULL;
// }
} else {
if (participant.type == ParticipantType.ORGANIZER) {
return RegistrationInfo.ORGANIZER;
} else if (participant.type == ParticipantType.REGISTERED) {
return RegistrationInfo.REGISTERED;
} else if (participant.type == ParticipantType.REGISTERED_NO_SHOW) {
return RegistrationInfo.REGISTERED_NO_SHOW;
} else {
checkState(participant.type == ParticipantType.WAIT_LISTED,
"unknown participant type: " + participant.type);
return RegistrationInfo.WAIT_LISTED;
}
}
}
// TODO(avaliani): Always calculating eval permission is expensive in the long run. It results
// in additional loads even for read operations. Since we're fetching the current user
// transactionless the user should be saved in the session cache, so the overhead is not
// terrible. But this should be eliminated if possible.
@Override
protected Permission evalPermission() {
User currentUser = ofy().transactionless().load().key(getCurrentUserKey()).now();
if ((currentUser != null) &&
currentUser.hasOrgMembership(KeyWrapper.toKey(organization),
Organization.Role.ORGANIZER)) {
return Permission.ALL;
}
return Permission.READ;
}
public static void upsertParticipant(
Key<Event> eventKey,
Key<User> userKey,
ParticipantType participantType) {
SourceEventSyncUtil.upsertParticipant(eventKey, userKey, participantType);
ofy().transact(new UpsertParticipantTxn(
eventKey, userKey, participantType));
}
@Data
@EqualsAndHashCode(callSuper=false)
public static class UpsertParticipantTxn extends VoidWork {
private final Key<Event> eventKey;
private final Key<User> userToUpsertKey;
private final ParticipantType participantType;
public void vrun() {
Event event = ofy().load().key(eventKey).now();
if (event == null) {
throw ErrorResponseMsg.createException("event not found",
ErrorInfo.Type.BAD_REQUEST);
}
EventParticipant participantToUpsert = event.tryFindParticipant(userToUpsertKey);
// Users that can not edit the event are restricted to only adding themselves to the
// registered list or the waiting list. Additionally, users with edit permissions
// bypass all event registration and waiting list limits.
if (!event.permission.canEdit()) {
if (!userToUpsertKey.equals(getCurrentUserKey())) {
throw ErrorResponseMsg.createException(
"only organizers can add any participant to the event",
ErrorInfo.Type.BAD_REQUEST);
}
if (participantType == ParticipantType.ORGANIZER) {
throw ErrorResponseMsg.createException(
"insufficent priveleges to make the current user an organizer of the event",
ErrorInfo.Type.BAD_REQUEST);
}
if (participantType == ParticipantType.REGISTERED_NO_SHOW) {
throw ErrorResponseMsg.createException(
"insufficent priveleges to change the state of the current user to REGISTERED_NO_SHOW",
ErrorInfo.Type.BAD_REQUEST);
}
if ((participantToUpsert != null) && (participantToUpsert.getType() == participantType)) {
// Nothing to do.
return;
}
if (participantType == ParticipantType.REGISTERED) {
if (event.registeredUsers.size() >= event.maxRegistrations) {
throw ErrorResponseMsg.createException(
"the event has reached the max registration limit",
ErrorInfo.Type.LIMIT_REACHED);
}
} else {
checkState(participantType == ParticipantType.WAIT_LISTED);
// if (event.waitListedUsers.size() >= event.maxWaitingList) {
// throw ErrorResponseMsg.createException(
// "the event has reached the max waiting list limit",
// ErrorInfo.Type.LIMIT_REACHED);
// }
}
}
if (event.status == Status.COMPLETED) {
if (participantType == ParticipantType.ORGANIZER) {
throw ErrorResponseMsg.createException(
"organizers can not be added after event completion",
ErrorInfo.Type.BAD_REQUEST);
}
if (participantType == ParticipantType.WAIT_LISTED) {
throw ErrorResponseMsg.createException(
"wait listed users can not be added after event completion",
ErrorInfo.Type.BAD_REQUEST);
}
} else {
if (participantType == ParticipantType.REGISTERED_NO_SHOW) {
throw ErrorResponseMsg.createException(
"users can not be marked no show until the event is marked complete",
ErrorInfo.Type.BAD_REQUEST);
}
}
MutationType mutationType;
if (participantToUpsert == null) {
// TODO(avaliani): organizers should not in theory be allowed to add arbitrary users.
// However, this simplifies testing so we're going to allow it for now.
participantToUpsert =
new EventParticipant(userToUpsertKey, participantType);
event.participants.add(participantToUpsert);
mutationType = MutationType.INSERT;
} else {
ParticipantType prevParticipantType = participantToUpsert.getType();
if (participantType == prevParticipantType) {
// Nothing to do.
return;
}
if ((event.status == Status.COMPLETED) &&
(prevParticipantType == ParticipantType.ORGANIZER)) {
throw ErrorResponseMsg.createException(
"organizers can not be updated after event completion",
ErrorInfo.Type.BAD_REQUEST);
}
participantToUpsert.setType(participantType);
mutationType = MutationType.UPDATE;
}
// validateEvent() ensures that there is at least one organizer.
event.processParticipantMutation(participantToUpsert, mutationType);
BaseDao.partialUpdate(event);
}
}
public static void deleteParticipant(
Key<Event> eventKey,
Key<User> userKey) {
SourceEventSyncUtil.deleteParticipant(eventKey, userKey);
ofy().transact(new DeleteParticipantTxn(eventKey, userKey));
}
@Data
@EqualsAndHashCode(callSuper=false)
public static class DeleteParticipantTxn extends VoidWork {
private final Key<Event> eventKey;
private final Key<User> userToRemoveKey;
public void vrun() {
Event event = ofy().load().key(eventKey).now();
if (event == null) {
throw ErrorResponseMsg.createException("event not found",
ErrorInfo.Type.BAD_REQUEST);
}
if (!event.permission.canEdit() && !userToRemoveKey.equals(getCurrentUserKey())) {
throw ErrorResponseMsg.createException(
"only organizers can remove any participant from the event",
ErrorInfo.Type.BAD_REQUEST);
}
EventParticipant participant = event.tryFindParticipant(userToRemoveKey);
if (participant != null) {
if (event.status == Status.COMPLETED) {
if (participant.getType() == ParticipantType.ORGANIZER) {
throw ErrorResponseMsg.createException(
"organizers can not be removed after event completion",
ErrorInfo.Type.BAD_REQUEST);
}
if (canWriteReview(participant)) {
Key<Review> participantReviewKey =
Review.getKeyForUser(Key.create(event), KeyWrapper.toKey(participant.getUser()));
Review participantReview = ofy().load().key(participantReviewKey).now();
if (participantReview != null) {
throw ErrorResponseMsg.createException(
"users that have written a review can not be removed after event completion",
ErrorInfo.Type.BAD_REQUEST);
}
}
}
event.participants.remove(participant);
// validateEvent() ensures that there is at least one organizer.
event.processParticipantMutation(participant, MutationType.DELETE);
BaseDao.partialUpdate(event);
}
}
}
@Data
@NoArgsConstructor
public static final class EventParticipant {
@Index
private KeyWrapper<User> user;
private ParticipantType type;
private int numVolunteers;
private double hoursWorked;
public EventParticipant(Key<User> user, ParticipantType type) {
this(user, type, 1, 0);
}
public EventParticipant(Key<User> user, ParticipantType type, int numVolunteers,
double hoursWorked) {
this.user = KeyWrapper.create(user);
this.type = type;
this.numVolunteers = numVolunteers;
this.hoursWorked = hoursWorked;
}
public static Predicate<EventParticipant> userPredicate(final Key<User> userKey) {
return new Predicate<EventParticipant>() {
@Override
public boolean apply(@Nullable EventParticipant input) {
return KeyWrapper.toKey(input.user).equals(userKey);
}
};
}
}
public static void mutateEventReviewForCurrentUser(Key<Event> eventKey, @Nullable Review review) {
ofy().transact(new MutateEventReviewTxn(eventKey, getCurrentUserKey(), review));
}
@Data
@EqualsAndHashCode(callSuper=false)
private static class MutateEventReviewTxn extends VoidWork {
private final Key<Event> eventKey;
private final Key<User> userKey;
@Nullable
private final Review review;
public void vrun() {
Event event = ofy().load().key(eventKey).now();
if (event == null) {
throw ErrorResponseMsg.createException("event not found", ErrorInfo.Type.BAD_REQUEST);
}
EventParticipant participantDetails = event.tryFindParticipant(userKey);
if ((participantDetails == null) || !canWriteReview(participantDetails)) {
throw ErrorResponseMsg.createException(
"only users that participated in the event (non-organizers) can provide an event review",
ErrorInfo.Type.BAD_REQUEST);
}
if (event.status != Status.COMPLETED) {
throw ErrorResponseMsg.createException(
"only events that have completed can be reviewed", ErrorInfo.Type.BAD_REQUEST);
}
processReviewMutation(event, userKey, review);
BaseDao.partialUpdate(event);
}
}
public static void processReviewMutation(Event event, Key<User> userKey,
@Nullable Review review) {
boolean ratingMutated = false;
Key<Review> expReviewKey = Review.getKeyForUser(Key.create(event), userKey);
if (review != null) {
review.initPreUpsert(Key.create(event), userKey);
if (!Key.create(review).equals(expReviewKey)) {
throw ErrorResponseMsg.createException(
format("review key [%s] does not match expected review key [%s]",
Key.create(review).toString(), expReviewKey.toString()),
ErrorInfo.Type.BAD_REQUEST);
}
}
Review existingReview = ofy().load().key(expReviewKey).now();
if (existingReview != null) {
event.rating.deleteRating(existingReview.getRating());
ratingMutated = true;
}
if (review == null) {
if (existingReview != null) {
BaseDao.delete(Key.create(existingReview));
}
} else {
event.rating.addRating(review.getRating());
ratingMutated = true;
// An upsert will automatically delete the old review since the key for the new and the
// old review is the same.
BaseDao.upsert(review);
}
if (ratingMutated) {
event.processRatingUpdate();
}
}
private static boolean canWriteReview(EventParticipant participant) {
return participant.getType() == ParticipantType.REGISTERED;
}
/**
* This class updates the organizer event ratings for all organizers associated with an event.
*
* Note that all methods in this class should be invoked from within the context of a transaction.
*/
@Data
@NoArgsConstructor
public static class DerivedRatingTracker {
// NOTE: The embedded lists are safe since DerivedRatingWrapper has been modified to avoid
// encountering the objectify serialization bug (issue #127).
List<DerivedRatingWrapper> processed = Lists.newArrayList();
List<DerivedRatingWrapper> pending = Lists.newArrayList();
public DerivedRatingTracker(Event event) {
for (EventParticipant participant : event.participants) {
if (participant.getType() == ParticipantType.ORGANIZER) {
processParticipantMutation(event, participant, MutationType.INSERT);
}
}
processed.add(new DerivedRatingWrapper(
new OrganizationDerivedRating(KeyWrapper.toKey(event.organization))));
}
public void processParticipantMutation(Event event, EventParticipant participant,
MutationType mutationType) {
processParticipantMutation(event, KeyWrapper.toKey(participant.getUser()),
((mutationType == MutationType.DELETE) ? false :
(participant.getType() == ParticipantType.ORGANIZER)));
}
private void processParticipantMutation(Event event, Key<User> userKey, boolean isOrganizer) {
boolean pendingQueueWasEmpty = pending.isEmpty();
DerivedRatingWrapper derivedRating = tryFindOrganizerDerivedRating(pending, userKey);
if (derivedRating != null) {
// Nothing to do, already queued.
return;
}
derivedRating = tryFindOrganizerDerivedRating(processed, userKey);
if (derivedRating == null) {
if (isOrganizer) {
if (event.getRating().getCount() > 0) {
pending.add(new DerivedRatingWrapper(new OrganizerDerivedRating(userKey)));
} else {
processed.add(new DerivedRatingWrapper(new OrganizerDerivedRating(userKey)));
}
}
} else {
if (isOrganizer) {
if (!event.getRating().equals(derivedRating.getOrganizerRating().accumulatedRating)) {
processed.remove(derivedRating);
pending.add(derivedRating);
}
} else {
processed.remove(derivedRating);
if (derivedRating.getOrganizerRating().accumulatedRating.getCount() > 0) {
pending.add(derivedRating);
}
}
}
queueProcessingTask(event, pendingQueueWasEmpty);
}
private static DerivedRatingWrapper tryFindOrganizerDerivedRating(
List<DerivedRatingWrapper> list, Key<User> organizerKey) {
return Iterables.tryFind(list, organizerPredicate(organizerKey)).orNull();
}
public static Predicate<DerivedRatingWrapper> organizerPredicate(
final Key<User> organizerKey) {
return new Predicate<DerivedRatingWrapper>() {
@Override
public boolean apply(@Nullable DerivedRatingWrapper input) {
if (input.getOrganizerRating() != null) {
return KeyWrapper.toKey(input.getOrganizerRating().organizer).equals(organizerKey);
} else {
return false;
}
}
};
}
public void processRatingUpdate(Event event) {
boolean pendingQueueWasEmpty = pending.isEmpty();
pending.addAll(processed);
processed.clear();
queueProcessingTask(event, pendingQueueWasEmpty);
}
private void queueProcessingTask(Event event, boolean pendingQueueWasEmpty) {
if (pendingQueueWasEmpty && !pending.isEmpty()) {
ProcessRatingsServlet.enqueueTask(event);
}
}
public void processPendingRating(Event event) {
DerivedRatingWrapper pendingRating = pending.remove(0);
DerivedRatingWrapper processedRating = pendingRating.processPendingRating(event);
if (processedRating != null) {
processed.add(processedRating);
}
}
public boolean hasPendingRatings() {
return !pending.isEmpty();
}
private interface DerivedRating {
@Nullable
public DerivedRatingWrapper processPendingRating(Event event);
}
/*
* This class works around the objectify limitation that embedded classes can not be
* polymorphic.
*/
@Data
@NoArgsConstructor
private static class DerivedRatingWrapper implements DerivedRating {
private OrganizerDerivedRating organizerRating = new OrganizerDerivedRating();
private OrganizationDerivedRating organizationRating = new OrganizationDerivedRating();
public DerivedRatingWrapper(OrganizerDerivedRating organizerRating) {
this.organizerRating = organizerRating;
}
public DerivedRatingWrapper(OrganizationDerivedRating organizationRating) {
this.organizationRating = organizationRating;
}
public OrganizerDerivedRating getOrganizerRating() {
return organizerRating.isNull() ? null : organizerRating;
}
public OrganizationDerivedRating getOrganizationRating() {
return organizationRating.isNull() ? null : organizationRating;
}
@Override
public DerivedRatingWrapper processPendingRating(Event event) {
if (!organizerRating.isNull()) {
return organizerRating.processPendingRating(event);
} else {
return organizationRating.processPendingRating(event);
}
}
}
@Data
@NoArgsConstructor
public static class OrganizerDerivedRating implements DerivedRating {
NullableKeyWrapper<User> organizer = NullableKeyWrapper.create();
AggregateRating accumulatedRating = AggregateRating.create();
public OrganizerDerivedRating(Key<User> organizerKey) {
this(organizerKey, null);
}
public OrganizerDerivedRating(Key<User> organizerKey,
@Nullable AggregateRating ratingToCopy) {
this.organizer = NullableKeyWrapper.create(organizerKey);
accumulatedRating = AggregateRating.create(ratingToCopy);
}
@XmlTransient
public boolean isNull() {
return organizer.isNull();
}
@Override
public DerivedRatingWrapper processPendingRating(Event event) {
Key<User> userKey = KeyWrapper.toKey(organizer);
User user = ofy().load().key(userKey).now();
if (user == null) {
// Nothing to do. User no longer exists.
return null;
}
// Delete the old rating.
user.getEventOrganizerRating().deleteAggregateRating(accumulatedRating);
// Add the new rating.
DerivedRatingWrapper processedRating = null;
EventParticipant eventParticipant = event.tryFindParticipant(userKey);
if ((eventParticipant != null) &&
(eventParticipant.getType() == ParticipantType.ORGANIZER)) {
user.getEventOrganizerRating().addAggregateRating(event.getRating());
processedRating = new DerivedRatingWrapper(
new OrganizerDerivedRating(userKey, event.getRating()));
}
BaseDao.partialUpdate(user);
return processedRating;
}
}
@Data
@NoArgsConstructor
public static class OrganizationDerivedRating implements DerivedRating {
NullableKeyWrapper<Organization> organization = NullableKeyWrapper.create();
AggregateRating accumulatedRating = AggregateRating.create();
public OrganizationDerivedRating(Key<Organization> organizerKey) {
this(organizerKey, null);
}
private OrganizationDerivedRating(Key<Organization> organizerKey,
@Nullable AggregateRating ratingToCopy) {
this.organization = NullableKeyWrapper.create(organizerKey);
accumulatedRating = AggregateRating.create(ratingToCopy);
}
@XmlTransient
public boolean isNull() {
return organization.isNull();
}
@Override
public DerivedRatingWrapper processPendingRating(Event event) {
Key<Organization> orgKey = KeyWrapper.toKey(organization);
Organization org = ofy().load().key(orgKey).now();
if (org == null) {
// Nothing to do. Org no longer exists.
return null;
}
// Delete the old rating.
org.getEventRating().deleteAggregateRating(accumulatedRating);
// Add the new rating.
org.getEventRating().addAggregateRating(event.getRating());
BaseDao.partialUpdate(org);
return new DerivedRatingWrapper(
new OrganizationDerivedRating(orgKey, event.getRating()));
}
}
}
public static void processDerivedRatings(Key<Event> eventKey) {
ProcessDerivedRatingsTxn ratingsTxn;
do {
ratingsTxn = new ProcessDerivedRatingsTxn(eventKey);
ofy().transact(ratingsTxn);
} while (ratingsTxn.isWorkPending());
}
@Data
@EqualsAndHashCode(callSuper=false)
public static class ProcessDerivedRatingsTxn extends VoidWork {
private final Key<Event> eventKey;
private boolean workPending;
public void vrun() {
Event event = ofy().load().key(eventKey).now();
if ((event == null) || !event.hasPendingRatings()) {
// The event may no longer exist or there may be no work pending.
return;
}
event.processPendingRating();
workPending = event.hasPendingRatings();
BaseDao.partialUpdate(event);
}
}
@Data
@NoArgsConstructor
public static class CompletionTaskTracker {
// NOTE: The embedded lists are safe since CompletionTaskWrapper has been modified to avoid
// encountering the objectify serialization bug (issue #127).
List<CompletionTaskWrapper> tasksPending = Lists.newArrayList();
List<CompletionTaskWrapper> tasksProcessed = Lists.newArrayList();
public CompletionTaskTracker(Event event) {
for (EventParticipant participant : event.participants) {
if (participant.type.countAsAttended() || participant.type.countAsNoShow()) {
tasksPending.add(
new CompletionTaskWrapper(
new ParticipantCompletionTask(KeyWrapper.toKey(participant.user))));
}
}
// Parent orgs also accrue Karma points.
List<Key<Organization>> allOrgs =
Organization.getOrgAndAncestorOrgKeys(KeyWrapper.toKey(event.organization));
for (Key<Organization> orgKey : allOrgs) {
tasksPending.add(
new CompletionTaskWrapper(
new OrganizationCompletionTask(orgKey)));
}
}
public void processParticipantMutation(Event event, EventParticipant participant,
MutationType mutationType) {
// Update org karma points. We only have to update orgs that have been processed.
List<CompletionTaskWrapper> orgCompletionTasks = findOrgCompletionTasks(tasksProcessed);
for (CompletionTaskWrapper completionTask : orgCompletionTasks) {
if (completionTask.getOrganizationTask().updateRequired(event)) {
tasksProcessed.remove(completionTask);
tasksPending.add(completionTask);
}
}
// Process participant.
Key<User> participantKey = KeyWrapper.toKey(participant.getUser());
CompletionTaskWrapper completionTask =
tryFindParticipantCompletionTask(tasksPending, participantKey);
if (completionTask != null) {
// Nothing to do, already queued.
return;
}
completionTask = tryFindParticipantCompletionTask(tasksProcessed, participantKey);
if (completionTask == null) {
// Since participant mutation post-event completion is rare, we'll always take the hit
// of updating the event.
tasksPending.add(new CompletionTaskWrapper(
new ParticipantCompletionTask(participantKey)));
} else {
tasksProcessed.remove(completionTask);
tasksPending.add(completionTask);
}
}
private void processPendingTask(Event event) {
if (tasksPending()) {
CompletionTaskWrapper pendingTask = tasksPending.remove(0);
CompletionTaskWrapper completedTask = pendingTask.processPendingTask(event);
if (completedTask != null) {
tasksProcessed.add(completedTask);
}
}
}
public boolean tasksPending() {
return tasksPending.size() > 0;
}
private static CompletionTaskWrapper tryFindParticipantCompletionTask(
List<CompletionTaskWrapper> list, Key<User> participantKey) {
CompletionTaskWrapper task =
Iterables.tryFind(list, participantPredicate(participantKey)).orNull();
return task;
}
private static Predicate<CompletionTaskWrapper> participantPredicate(
final Key<User> participantKey) {
return new Predicate<CompletionTaskWrapper>() {
@Override
public boolean apply(@Nullable CompletionTaskWrapper input) {
if (input.getParticipantTask() != null) {
return KeyWrapper.toKey(input.getParticipantTask().participant).equals(participantKey);
} else {
return false;
}
}
};
}
private List<CompletionTaskWrapper> findOrgCompletionTasks(
List<CompletionTaskWrapper> list) {
Predicate<CompletionTaskWrapper> orgPredicate = new Predicate<CompletionTaskWrapper>() {
@Override
public boolean apply(@Nullable CompletionTaskWrapper input) {
return input.getOrganizationTask() != null;
}
};
return Lists.newArrayList(Iterables.filter(list, orgPredicate));
}
private interface CompletionTask {
@Nullable
public CompletionTaskWrapper processPendingTask(Event event);
}
/*
* This class works around the objectify limitation that embedded classes can not be
* polymorphic.
*/
@Data
@NoArgsConstructor
@VisibleForTesting
static class CompletionTaskWrapper implements CompletionTask {
// Instantiate each object to workaround objectify serialization bug (issue #127).
private ParticipantCompletionTask participantTask = new ParticipantCompletionTask();
private OrganizationCompletionTask organizationTask = new OrganizationCompletionTask();
public CompletionTaskWrapper(ParticipantCompletionTask participantTask) {
this.participantTask = participantTask;
}
public CompletionTaskWrapper(OrganizationCompletionTask organizationTask) {
this.organizationTask = organizationTask;
}
@Override
public CompletionTaskWrapper processPendingTask(Event event) {
if (getParticipantTask() != null) {
return participantTask.processPendingTask(event);
} else {
return organizationTask.processPendingTask(event);
}
}
public ParticipantCompletionTask getParticipantTask() {
return participantTask.isNull() ? null : participantTask;
}
public OrganizationCompletionTask getOrganizationTask() {
return organizationTask.isNull() ? null : organizationTask;
}
}
@Data
@NoArgsConstructor
@VisibleForTesting
static class ParticipantCompletionTask implements CompletionTask {
private NullableKeyWrapper<User> participant = NullableKeyWrapper.create();
private int karmaPointsAssigned;
public ParticipantCompletionTask(Key<User> participantKey) {
this(participantKey, 0);
}
public ParticipantCompletionTask(Key<User> participantKey, int karmaPointsAssigned) {
this.participant = NullableKeyWrapper.create(participantKey);
this.karmaPointsAssigned = karmaPointsAssigned;
}
@XmlTransient
public boolean isNull() {
return participant.isNull();
}
@Override
public CompletionTaskWrapper processPendingTask(Event event) {
Key<User> participantKey = KeyWrapper.toKey(participant);
User participant = ofy().load().key(participantKey).now();
if (participant == null) {
// Nothing to do. User no longer exists.
return null;
}
// Delete the previously assigned karma points and attendance history.
participant.setKarmaPoints(participant.getKarmaPoints() - karmaPointsAssigned);
participant.removeFromEventAttendanceHistory(Key.create(event));
// Assign the new karma points and attendance history if the participant is still part
// of the event.
CompletionTaskWrapper completedTask = null;
EventParticipant eventParticipant = event.tryFindParticipant(participantKey);
if (eventParticipant != null) {
if (eventParticipant.type.countAsAttended()) {
participant.setKarmaPoints(participant.getKarmaPoints() + event.karmaPoints);
participant.addToAttendanceHistory(new AttendanceRecord(event, true));
completedTask = new CompletionTaskWrapper(
new ParticipantCompletionTask(participantKey, event.karmaPoints));
} else if (eventParticipant.type.countAsNoShow()) {
participant.addToAttendanceHistory(new AttendanceRecord(event, false));
completedTask = new CompletionTaskWrapper(
new ParticipantCompletionTask(participantKey));
}
}
BaseDao.partialUpdate(participant);
return completedTask;
}
}
@Data
@NoArgsConstructor
@VisibleForTesting
static class OrganizationCompletionTask implements CompletionTask {
private NullableKeyWrapper<Organization> organization = NullableKeyWrapper.create();
private int karmaPointsAssigned;
public OrganizationCompletionTask(Key<Organization> orgKey) {
this(orgKey, 0);
}
private OrganizationCompletionTask(Key<Organization> orgKey, int karmaPointsAssigned) {
this.organization = NullableKeyWrapper.create(orgKey);
this.karmaPointsAssigned = karmaPointsAssigned;
}
@XmlTransient
public boolean isNull() {
return organization.isNull();
}
@Override
public CompletionTaskWrapper processPendingTask(Event event) {
Key<Organization> orgKey = KeyWrapper.toKey(organization);
Organization org = ofy().load().key(orgKey).now();
if (org == null) {
// Nothing to do. Org no longer exists.
return null;
}
org.setKarmaPoints(
org.getKarmaPoints() + computeImpactKarmaPoints(event) - karmaPointsAssigned);
BaseDao.partialUpdate(org);
return new CompletionTaskWrapper(
new OrganizationCompletionTask(orgKey, computeImpactKarmaPoints(event)));
}
public boolean updateRequired(Event event) {
return computeImpactKarmaPoints(event) != karmaPointsAssigned;
}
private int computeImpactKarmaPoints(Event event) {
return event.karmaPoints * event.getNumAttending();
}
}
}
public static void processEventCompletionTasks(Key<Event> eventKey) {
ProcessEventCompletionTasksTxn completionTasksTxn;
do {
completionTasksTxn = new ProcessEventCompletionTasksTxn(eventKey);
ofy().transact(completionTasksTxn);
} while (completionTasksTxn.isWorkPending());
}
@Data
@EqualsAndHashCode(callSuper=false)
private static class ProcessEventCompletionTasksTxn extends VoidWork {
private final Key<Event> eventKey;
private boolean workPending;
public void vrun() {
Event event = ofy().load().key(eventKey).now();
if ((event == null) || event.completionProcessed) {
return;
}
event.processCompletionTasks();
workPending = !event.completionProcessed;
BaseDao.partialUpdate(event);
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static final class SourceEventInfo {
// An external id uniquely identifying the source event.
private String id;
private Date lastModifiedDate;
}
}