Subversion Repositories SmartDukaan

Rev

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;

@Component
public 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;

    @Autowired
    UserCampaignRepository userCampaignRepository;
    @Autowired
    UserRepository dtrUserRepository;
    @Autowired
    UserAccountRepository userAccountRepository;
    @Autowired
    NotificationCampaignRepository notificationCampaignRepository;
    @Autowired
    DeviceRepository deviceRepository;

    @Autowired
    PartnerRegionRepository partnerRegionRepository;


    @Autowired
    FofoStoreRepository fofoStoreRepository;
    @Autowired
    CsService csService;
    @Autowired
    RetailerService retailerService;
    @Autowired
    CustomerRepository customerRepository;
    @Autowired
    PushNotificationRepository pushNotificationRepository;
    @Autowired
    NotificationScheduleRepository notificationScheduleRepository;
    @Autowired
    private Gson gson;
    @Value("${prod}")
    private boolean isProd;

    @Autowired
    private WhatsappMessageRepository whatsappMessageRepository;

    @Autowired
    private WhatsappMessageService whatsappMessageService;

    @Autowired
    private BotPenguinWhatsappService botPenguin;

    @Override
    public 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;
    }

    @Override
    public 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);
        }
    }

    @Override
    public 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();
    }

    @Override
    public boolean cancelSchedule(int scheduleId) {
        return notificationScheduleRepository.cancelIfScheduled(scheduleId) == 1;
    }

    @Override
    public int resolveAudienceSize(NotificationTargetCriteria criteria) throws ProfitMandiBusinessException {
        if (criteria == null) return 0;
        return resolveTargetUserIds(criteria).size();
    }

    @Override
    public 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();
    }

    @Override
    public 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);
    }

    @Override
    public 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);
    }

    @Override
    public 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);
        }

    }

    @Override
    public 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>
     */
    @Override
    public 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);
    }

    @Override
    public boolean sendPaymentLink(String mobile, String paymentLink) throws Exception {
        return botPenguin.send(WhatsappFlow.PAYMENT_LINK, mobile,
                Collections.singletonList(paymentLink), null, null, null);
    }

    @Override
    public 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);
    }

    @Override
    public 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);
    }

    @Override
    public 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);
    }

    @Override
    public 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);
    }

    @Override
    public 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);
    }

    @Override
    public 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();
    }

}