Rev 37409 | View as "text/plain" | Blame | Compare with Previous | Last modification | View Log | RSS feed
package com.spice.profitmandi.service;import com.google.gson.Gson;import com.spice.profitmandi.common.enumuration.MessageType;import com.spice.profitmandi.common.enumuration.NotificationFormat;import com.spice.profitmandi.common.enumuration.ScheduleStatus;import com.spice.profitmandi.common.exception.ProfitMandiBusinessException;import com.spice.profitmandi.common.model.CustomRetailer;import com.spice.profitmandi.common.model.NotificationTargetCriteria;import com.spice.profitmandi.common.model.ProfitMandiConstants;import com.spice.profitmandi.common.model.SendNotificationModel;import com.spice.profitmandi.dao.entity.auth.AuthUser;import com.spice.profitmandi.dao.entity.dtr.*;import com.spice.profitmandi.dao.entity.fofo.Customer;import com.spice.profitmandi.dao.entity.user.Device;import com.spice.profitmandi.dao.entity.whatsapp.WhatsappMessage;import com.spice.profitmandi.dao.model.SimpleCampaignParams;import com.spice.profitmandi.dao.repository.catalog.DeviceRepository;import com.spice.profitmandi.dao.repository.cs.CsService;import com.spice.profitmandi.dao.repository.cs.PartnerRegionRepository;import com.spice.profitmandi.dao.repository.dtr.*;import com.spice.profitmandi.dao.repository.fofo.CustomerRepository;import com.spice.profitmandi.dao.repository.whatsapp.WhatsappMessageRepository;import com.spice.profitmandi.service.user.RetailerService;import com.spice.profitmandi.service.whatsapp.*;import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Component;import org.springframework.transaction.annotation.Propagation;import org.springframework.transaction.annotation.Transactional;import java.nio.charset.StandardCharsets;import java.time.LocalDate;import java.time.LocalDateTime;import java.util.*;import java.util.stream.Collectors;@Componentpublic class NotificationServiceImpl implements NotificationService {private static final Logger LOGGER = LogManager.getLogger(NotificationServiceImpl.class);// Region id that represents "all partners". Schemes meant for everyone are tagged to this region;// partner_region is not populated for it, so it must be expanded to all partners here.private static final int ALL_PARTNERS_REGION = 5;@AutowiredUserCampaignRepository userCampaignRepository;@AutowiredUserRepository dtrUserRepository;@AutowiredUserAccountRepository userAccountRepository;@AutowiredNotificationCampaignRepository notificationCampaignRepository;@AutowiredDeviceRepository deviceRepository;@AutowiredPartnerRegionRepository partnerRegionRepository;@AutowiredFofoStoreRepository fofoStoreRepository;@AutowiredCsService csService;@AutowiredRetailerService retailerService;@AutowiredCustomerRepository customerRepository;@AutowiredPushNotificationRepository pushNotificationRepository;@AutowiredNotificationScheduleRepository notificationScheduleRepository;@Autowiredprivate Gson gson;@Value("${prod}")private boolean isProd;@Autowiredprivate WhatsappMessageRepository whatsappMessageRepository;@Autowiredprivate WhatsappMessageService whatsappMessageService;@Autowiredprivate BotPenguinWhatsappService botPenguin;@Overridepublic void sendNotification(SendNotificationModel sendNotificationModel) throws ProfitMandiBusinessException {NotificationTargetCriteria criteria = new NotificationTargetCriteria(sendNotificationModel.getUserIds(),sendNotificationModel.getFofoIds(),sendNotificationModel.getStateIds(),sendNotificationModel.getRegionIds());NotificationCampaign nc = buildAndPersistCampaign(sendNotificationModel, criteria);List<LocalDateTime> schedule = sendNotificationModel.getScheduledTimestamps();if (schedule != null && !schedule.isEmpty()) {LocalDateTime now = LocalDateTime.now();for (LocalDateTime ts : schedule) {NotificationSchedule ns = new NotificationSchedule();ns.setCampaignId(nc.getId());ns.setScheduledTimestamp(ts);ns.setStatus(ScheduleStatus.SCHEDULED);ns.setCreatedTimestamp(now);notificationScheduleRepository.persist(ns);}return;}Set<Integer> userIds = resolveTargetUserIds(criteria);if (userIds.isEmpty()) {LOGGER.info("Failed to send notification to any retailer with this model - {}", sendNotificationModel);return;}fanOutToUsers(nc.getId(), userIds, nc.getFormat());}private NotificationCampaign buildAndPersistCampaign(SendNotificationModel model, NotificationTargetCriteria criteria) {SimpleCampaignParams scp = new SimpleCampaignParams();scp.setMessage(model.getMessage());scp.setTitle(model.getTitle());scp.setImageUrl(model.getImageUrl());scp.setType(model.getType());scp.setUrl(model.getUrl());scp.setShowImage(model.getShowImage());scp.setExpireTimestamp(model.getExpiresat());// Pop-up / Story fields — silently no-op for PUSH since model getters return null.scp.setButtonLabel(model.getButtonLabel());scp.setShowCta(model.getShowCta());scp.setShowClose(model.getShowClose());scp.setShowOnPage(model.getShowOnPage());scp.setAutoCloseSeconds(model.getAutoCloseSeconds());scp.setFrequencyCap(model.getFrequencyCap());scp.setLiveFrom(model.getLiveFrom());scp.setLiveUntil(model.getLiveUntil());scp.setStoryCards(model.getStoryCards());scp.setPostInCategory(model.getPostInCategory());scp.setPlayDurationSeconds(model.getPlayDurationSeconds());scp.setExpiresAfterPolicy(model.getExpiresAfterPolicy());scp.setImages(model.getImages());scp.setLinkedEntities(model.getLinkedEntities());NotificationCampaign nc = new NotificationCampaign();nc.setName(model.getCampaignName());nc.setImplementationType("SimpleCampaign");nc.setImplementationParams(gson.toJson(scp));nc.setMessageType(model.getMessageType());nc.setDocumentId(model.getDocumentId());nc.setCreatedTimestamp(LocalDateTime.now());nc.setTargetCriteria(gson.toJson(criteria));nc.setFormat(model.getFormat() != null ? model.getFormat() : NotificationFormat.PUSH);nc.setCategory(model.getCategory());nc.setCampaignGroupId(model.getCampaignGroupId());notificationCampaignRepository.persist(nc);return nc;}@Overridepublic Set<Integer> resolveTargetUserIds(NotificationTargetCriteria criteria) throws ProfitMandiBusinessException {Set<Integer> userIds = new HashSet<>();if (criteria.getUserIds() != null && !criteria.getUserIds().isEmpty()) {userIds.addAll(criteria.getUserIds());}if (criteria.getStateIds() != null && !criteria.getStateIds().isEmpty()) {List<Integer> fofoIds = fofoStoreRepository.selectByWarehouseIds(criteria.getStateIds()).stream().map(x -> x.getId()).collect(Collectors.toList());if (!fofoIds.isEmpty()) {userIds.addAll(userAccountRepository.selectUserIdsByRetailerIds(fofoIds));}}if (criteria.getRegionIds() != null && !criteria.getRegionIds().isEmpty()) {if (criteria.getRegionIds().contains(ALL_PARTNERS_REGION)) {// "ALL partners" region: partner_region only holds a sentinel, so expand to every partner.userIds.addAll(fofoStoreRepository.selectAllDtrUserIds());} else {List<Integer> fofoIds = partnerRegionRepository.selectAllByRegionIds(criteria.getRegionIds()).stream().map(x -> x.getFofoId()).collect(Collectors.toList());if (!fofoIds.isEmpty()) {userIds.addAll(userAccountRepository.selectUserIdsByRetailerIds(fofoIds));}}}return userIds;}private void fanOutToUsers(int campaignId, Set<Integer> userIds, NotificationFormat format) {LocalDateTime now = LocalDateTime.now();for (Integer userId : userIds) {UserCampaign uc = new UserCampaign();uc.setCampaignId(campaignId);uc.setUserId(userId);uc.setPushTimestamp(now);userCampaignRepository.persist(uc);}// FCM/APNs delivery only for PUSH. Pop-ups and Stories are pull-based (mobile fetches on app open),// so we still write user_campaign rows above but skip pushnotifications.if (format == null || format == NotificationFormat.PUSH) {List<Device> devices = deviceRepository.selectByUserIdAndModifiedTimestamp(new ArrayList<>(userIds),now.minusMonths(1), now);pushNotification(campaignId, devices);}}@Overridepublic void dispatchScheduled(int scheduleId) throws ProfitMandiBusinessException {NotificationSchedule ns = notificationScheduleRepository.selectById(scheduleId);if (ns == null) {LOGGER.warn("dispatchScheduled: schedule id {} not found", scheduleId);return;}if (ns.getStatus() != ScheduleStatus.SCHEDULED) {LOGGER.debug("dispatchScheduled: skip id {} in status {}", scheduleId, ns.getStatus());return;}// Atomic claim: only one worker transitions SCHEDULED -> DISPATCHED; others get 0 rows.int claimed = notificationScheduleRepository.claimForDispatch(scheduleId, LocalDateTime.now());if (claimed == 0) {LOGGER.debug("dispatchScheduled: id {} already claimed by another worker", scheduleId);return;}try {NotificationCampaign campaign = notificationCampaignRepository.selectById(ns.getCampaignId());if (campaign == null) {LOGGER.error("dispatchScheduled: campaign {} missing for schedule {}", ns.getCampaignId(), scheduleId);notificationScheduleRepository.markFailed(scheduleId);return;}NotificationTargetCriteria criteria = campaign.getTargetCriteria() == null? new NotificationTargetCriteria(): gson.fromJson(campaign.getTargetCriteria(), NotificationTargetCriteria.class);Set<Integer> userIds = resolveTargetUserIds(criteria);if (userIds.isEmpty()) {LOGGER.info("dispatchScheduled: no users resolved for schedule {} (campaign {})",scheduleId, campaign.getId());return;}fanOutToUsers(campaign.getId(), userIds, campaign.getFormat());} catch (RuntimeException e) {// Swallow: markFailed must commit, and cron shouldn't abort the batch on one bad row.LOGGER.error("dispatchScheduled: fan-out failed for schedule {}", scheduleId, e);notificationScheduleRepository.markFailed(scheduleId);}}@Override@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Throwable.class)public int dispatchDueScheduled(int batchSize) throws ProfitMandiBusinessException {List<Integer> due = notificationScheduleRepository.selectDueScheduledIds(LocalDateTime.now(), batchSize);if (due.isEmpty()) {return 0;}LOGGER.info("dispatchDueScheduled: dispatching {} scheduled notification(s)", due.size());for (Integer id : due) {dispatchScheduled(id);}return due.size();}@Overridepublic boolean cancelSchedule(int scheduleId) {return notificationScheduleRepository.cancelIfScheduled(scheduleId) == 1;}@Overridepublic int resolveAudienceSize(NotificationTargetCriteria criteria) throws ProfitMandiBusinessException {if (criteria == null) return 0;return resolveTargetUserIds(criteria).size();}@Overridepublic int addScheduleForCampaign(int campaignId, LocalDateTime scheduledAt) throws ProfitMandiBusinessException {NotificationCampaign nc = notificationCampaignRepository.selectById(campaignId);if (nc == null) {throw new ProfitMandiBusinessException("Campaign not found: ", campaignId, "- campaignId");}NotificationSchedule ns = new NotificationSchedule();ns.setCampaignId(campaignId);ns.setScheduledTimestamp(scheduledAt);ns.setStatus(ScheduleStatus.SCHEDULED);ns.setCreatedTimestamp(LocalDateTime.now());notificationScheduleRepository.persist(ns);LOGGER.info("addScheduleForCampaign: campaignId={} scheduledAt={} newScheduleId={}",campaignId, scheduledAt, ns.getId());return ns.getId();}@Overridepublic void sendNotificationToAll(SendNotificationModel sendNotificationModel) throws ProfitMandiBusinessException {sendNotificationModel.setUserIds(fofoStoreRepository.selectAllDtrUserIds());Set<AuthUser> authUsers = new HashSet<>(csService.getAuthUserByCategoryId(ProfitMandiConstants.TICKET_CATEGORY_RBM));authUsers.addAll(csService.getAuthUserByCategoryId(ProfitMandiConstants.TICKET_CATEGORY_CATEGORY));authUsers.addAll(csService.getAuthUserByCategoryId(ProfitMandiConstants.TICKET_CATEGORY_SALES));authUsers.addAll(csService.getAuthUserByCategoryId(ProfitMandiConstants.TICKET_CATEGORY_ABM));List<String> emailIds = authUsers.stream().map(x -> x.getEmailId()).collect(Collectors.toList());emailIds.add("devkinandan.lal@smartdukaan.com");List<User> systemUsers = dtrUserRepository.selectAllByEmailIds(emailIds);sendNotificationModel.getUserIds().addAll(systemUsers.stream().map(x -> x.getId()).collect(Collectors.toList()));this.sendNotification(sendNotificationModel);}@Overridepublic void sendNotificationToSystemUsers(SendNotificationModel sendNotificationModel) throws ProfitMandiBusinessException {Set<AuthUser> authUsers = new HashSet<>(csService.getAuthUserByCategoryId(ProfitMandiConstants.TICKET_CATEGORY_RBM));authUsers.addAll(csService.getAuthUserByCategoryId(ProfitMandiConstants.TICKET_CATEGORY_CATEGORY));authUsers.addAll(csService.getAuthUserByCategoryId(ProfitMandiConstants.TICKET_CATEGORY_SALES));authUsers.addAll(csService.getAuthUserByCategoryId(ProfitMandiConstants.TICKET_CATEGORY_ABM));List<String> emailIds = authUsers.stream().map(x -> x.getEmailId()).collect(Collectors.toList());emailIds.add("devkinandan.lal@smartdukaan.com");List<User> systemUsers = dtrUserRepository.selectAllByEmailIds(emailIds);sendNotificationModel.setUserIds(systemUsers.stream().map(x -> x.getId()).collect(Collectors.toList()));this.sendNotification(sendNotificationModel);}@Overridepublic void sendNotification(int fofoId, String campaignName, MessageType messageType, String title, String message)throws ProfitMandiBusinessException {SendNotificationModel sendNotificationModel = this.getDefaultNotificationModel();sendNotificationModel.setCampaignName(campaignName);sendNotificationModel.setMessageType(messageType);sendNotificationModel.setTitle(title);sendNotificationModel.setMessage(message);int userId = userAccountRepository.selectUserIdByRetailerId(fofoId);sendNotificationModel.setUserIds(Arrays.asList(userId));sendNotificationModel.setMessageType(MessageType.wallet);this.sendNotification(sendNotificationModel);}public SendNotificationModel getDefaultNotificationModel() {SendNotificationModel sendNotificationModel = new SendNotificationModel();sendNotificationModel.setType("url");sendNotificationModel.setUrl("https://app.smartdukaan.com/pages/home/notifications");sendNotificationModel.setExpiresat(LocalDateTime.now().plusDays(1));sendNotificationModel.setMessageType(MessageType.notification);return sendNotificationModel;}public void pushNotification(int cid, List<Device> devices) {for (Device device : devices) {PushNotifications pn = new PushNotifications();pn.setNotificationCampaignid(cid);pn.setDeviceId(device.getId());pn.setUserId(device.getUser_id());pushNotificationRepository.persist(pn);}}@Overridepublic void sendNotification(int fofoId, String campaignName, MessageType messageType, String title, String message, String url) throws ProfitMandiBusinessException {SendNotificationModel sendNotificationModel = this.getDefaultNotificationModel();sendNotificationModel.setCampaignName(campaignName);sendNotificationModel.setMessageType(messageType);sendNotificationModel.setTitle(title);sendNotificationModel.setMessage(message);int userId = userAccountRepository.selectUserIdByRetailerId(fofoId);sendNotificationModel.setUserIds(Arrays.asList(userId));sendNotificationModel.setMessageType(messageType);sendNotificationModel.setUrl(url);this.sendNotification(sendNotificationModel);}/*** Invoice PDF, sent as the template's document header.** <p>The upsell wording that used to be built here now lives in the approved template; only the* recipient's name and the PDF link cross the wire.</p>*/@Overridepublic boolean sendWhatsappInvoice(int customerId, int fofoId, String invoiceNumber, String whatsAppNo) throws Exception {if (!shouldSendWhatsappMessage(whatsAppNo)) {return false;}Customer customer = customerRepository.selectById(customerId);String mobileNumber = (whatsAppNo != null && !whatsAppNo.isEmpty()) ? whatsAppNo : customer.getMobileNumber();String mediaUrl = "https://partners.smartdukaan.com/wa-invoice-send/"+ Base64.getMimeEncoder().encodeToString(invoiceNumber.getBytes(StandardCharsets.UTF_8)) + ".pdf";String fileName = "INV-" + invoiceNumber.replace("/", "-") + ".pdf";LOGGER.info("sendWhatsappInvoice: mobile={} mediaUrl={}", mobileNumber, mediaUrl);return botPenguin.send(WhatsappFlow.INVOICE, mobileNumber,Arrays.asList(customerName(customer), storeName(fofoId)), mediaUrl, fileName,WhatsappMessageType.DOCUMENT);}@Overridepublic boolean sendPaymentLink(String mobile, String paymentLink) throws Exception {return botPenguin.send(WhatsappFlow.PAYMENT_LINK, mobile,Collections.singletonList(paymentLink), null, null, null);}@Overridepublic boolean sendOfferAnnouncement(String mobile, String offerName, String schemeType, String startDate,String endDate, String imageUrl) throws Exception {return botPenguin.send(WhatsappFlow.OFFER, mobile,Arrays.asList(offerName, schemeType, startDate, endDate), imageUrl, null,WhatsappMessageType.IMAGE);}@Overridepublic boolean sendBiddingLive(String mobile, List<String> lots, String mediaUrl) throws Exception {int slots = ProfitMandiConstants.WHATSAPP_TEMPLATE.BIDDING_LIVE.getBodyParamCount();if (lots.size() > slots) {LOGGER.warn("sendBiddingLive: {} lots available but the template holds {}; dropping {}",lots.size(), slots, lots.subList(slots, lots.size()));}List<String> params = new ArrayList<>(lots.subList(0, Math.min(lots.size(), slots)));// Meta rejects an empty parameter, so unused slots carry a dash rather than "".while (params.size() < slots) {params.add("-");}return botPenguin.send(WhatsappFlow.BIDDING_LIVE, mobile, params, mediaUrl, null,WhatsappMessageType.IMAGE);}@Overridepublic boolean sendLoanDefaultAlert(String mobile, String name, String outstandingAmount) throws Exception {LOGGER.info("Is Prod - {}", isProd);if (!isProd) {return false;}return botPenguin.send(WhatsappFlow.LOAN_ALERT, mobile,Arrays.asList(name, outstandingAmount), null, null, null);}@Overridepublic boolean sendOrderDelivered(String mobile, String airwayBillNumber) throws Exception {LOGGER.info("Is Prod - {}", isProd);if (!isProd) {return false;}return botPenguin.send(WhatsappFlow.ORDER_DELIVERED, mobile,Collections.singletonList(airwayBillNumber), null, null, null);}@Overridepublic boolean sendADLDWhatsappMessage(String mobile, String invoiceNumber, String mediaUrl,String fileName) throws Exception {LOGGER.info("Is Prod - {}", isProd);if (!isProd) {return false;}return botPenguin.send(WhatsappFlow.ADLD, mobile, Collections.singletonList(invoiceNumber),mediaUrl, fileName, WhatsappMessageType.DOCUMENT);}@Overridepublic boolean shouldSendWhatsappMessage(String mobile) {// Normalised rather than concatenated: BotPenguin stores the normalised MSISDN, so for anything// that is not already a bare ten digits the old "91" + mobile key could never match a stored row// and the throttle quietly never fired. Identical result for clean input.String destAddr = WhatsappNumbers.toMsisdn(mobile);if (destAddr == null) {return true;}boolean shouldSend = true;List<WhatsappMessage> whatsappMessages = whatsappMessageRepository.selectByDestAddr(destAddr, LocalDate.now());if (!whatsappMessages.isEmpty()) {long failedCount = whatsappMessages.stream().filter(x -> x.getFailed() != null && x.getFailed().equals("FAILED")).collect(Collectors.counting());if (failedCount >= 2) {shouldSend = false;}}return shouldSend;}/*** The store the purchase was made at, for the invoice template's second variable. Falls back to the* brand name: an unknown partner id must not stop an invoice going out.*/private String storeName(int fofoId) {try {CustomRetailer retailer = retailerService.getFofoRetailer(fofoId);String businessName = retailer == null ? null : retailer.getBusinessName();if (businessName != null && !businessName.trim().isEmpty()) {return businessName.trim();}} catch (Exception e) {LOGGER.warn("storeName: could not resolve fofoId {}", fofoId, e);}return "SmartDukaan";}/** Meta greets by name, so an empty first name would render "Hello ,". */private static String customerName(Customer customer) {String firstName = customer == null ? null : customer.getFirstName();return (firstName == null || firstName.trim().isEmpty()) ? "Customer" : firstName.trim();}}