Marketing Planning Module - Implementation Plan¶
Status: COMPLETE ✓¶
Completed: 2026-01-21
All components of the Marketing Planning module have been implemented: - Database schema and 8 JPA entities - 8 canonical entities with MarketingFacade (~1800 lines) - 44 REST API endpoints in MarketingApi - WhatsApp notifications for review workflow - Front-end UI (mktplan.html + mktplan.js) - Entity configuration and API role mappings
Executive Summary¶
This document provides a detailed implementation plan for the Marketing Planning module as specified in the Marketing Planning Requirements. The module enables team members to plan and manage social marketing campaigns with activities, media artifacts, workflow management, and WhatsApp notifications.
1. Database Design¶
1.1 Schema Overview¶
All tables will be created in the nts schema following TQPro naming conventions.
1.2 Tables¶
1.2.1 Team Member Table (mkt_teammember)¶
CREATE TABLE nts.mkt_teammember (
teammemberid INTEGER PRIMARY KEY,
userid INTEGER REFERENCES nts.reguser(id),
displayname VARCHAR(100) NOT NULL,
email VARCHAR(150),
whatsappnum VARCHAR(20),
role VARCHAR(50), -- 'admin', 'reviewer', 'creator'
active BOOLEAN DEFAULT TRUE,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
createdby INTEGER,
modified TIMESTAMP,
modifiedby INTEGER
);
CREATE SEQUENCE nts.mkt_teammember_seq START 1000;
1.2.2 Audience Segment Table (mkt_audience)¶
CREATE TABLE nts.mkt_audience (
audienceid INTEGER PRIMARY KEY,
segmentname VARCHAR(100) NOT NULL,
description VARCHAR(500),
active BOOLEAN DEFAULT TRUE,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
createdby INTEGER
);
CREATE SEQUENCE nts.mkt_audience_seq START 1000;
1.2.3 Campaign/Project Table (mkt_campaign)¶
CREATE TABLE nts.mkt_campaign (
campaignid INTEGER PRIMARY KEY,
campaignname VARCHAR(200) NOT NULL,
description TEXT,
goals TEXT,
targetedapproach TEXT,
status VARCHAR(30) DEFAULT 'CREATED',
-- CREATED, WORK_IN_PROGRESS, COMPLETED_SCHEDULED,
-- STARTED, ONGOING, PAUSED, ENDED
startdate DATE,
enddate DATE,
createdby INTEGER REFERENCES nts.mkt_teammember(teammemberid),
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
modifiedby INTEGER,
modified TIMESTAMP
);
CREATE SEQUENCE nts.mkt_campaign_seq START 1000;
1.2.4 Activity Table (mkt_activity)¶
CREATE TABLE nts.mkt_activity (
activityid INTEGER PRIMARY KEY,
campaignid INTEGER NOT NULL REFERENCES nts.mkt_campaign(campaignid),
activityname VARCHAR(200) NOT NULL,
activitytype VARCHAR(30) NOT NULL,
-- GOOGLE_AD, INSTAGRAM_REEL, INSTAGRAM_STORY, INSTAGRAM_CAROUSEL,
-- FACEBOOK_REEL, FACEBOOK_STORY, TIKTOK_STORY, TWITTER_POST, BLOG_POST
description TEXT,
storytext TEXT, -- Rich text/HTML formatted content
status VARCHAR(30) DEFAULT 'CREATED',
-- CREATED, ASSIGNED, WORK_IN_PROGRESS, SENT_FOR_REVIEW,
-- APPROVED, CANCELLED
startdate DATE,
duedate DATE,
reviewerid INTEGER REFERENCES nts.mkt_teammember(teammemberid),
createdby INTEGER REFERENCES nts.mkt_teammember(teammemberid),
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
modifiedby INTEGER,
modified TIMESTAMP
);
CREATE SEQUENCE nts.mkt_activity_seq START 1000;
CREATE INDEX idx_activity_campaign ON nts.mkt_activity(campaignid);
CREATE INDEX idx_activity_status ON nts.mkt_activity(status);
1.2.5 Activity Assignment Table (mkt_activityassignment)¶
CREATE TABLE nts.mkt_activityassignment (
assignmentid INTEGER PRIMARY KEY,
activityid INTEGER NOT NULL REFERENCES nts.mkt_activity(activityid),
teammemberid INTEGER NOT NULL REFERENCES nts.mkt_teammember(teammemberid),
assignedat TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
assignedby INTEGER REFERENCES nts.mkt_teammember(teammemberid),
active BOOLEAN DEFAULT TRUE
);
CREATE SEQUENCE nts.mkt_activityassignment_seq START 1000;
CREATE INDEX idx_assignment_activity ON nts.mkt_activityassignment(activityid);
CREATE INDEX idx_assignment_member ON nts.mkt_activityassignment(teammemberid);
1.2.6 Activity Media Table (mkt_activitymedia)¶
CREATE TABLE nts.mkt_activitymedia (
mediaid INTEGER PRIMARY KEY,
activityid INTEGER NOT NULL REFERENCES nts.mkt_activity(activityid),
mediatype VARCHAR(30) NOT NULL, -- VIDEO, IMAGE, GRAPHIC
mediaurl VARCHAR(500) NOT NULL,
filename VARCHAR(200),
description VARCHAR(500),
sortorder INTEGER DEFAULT 0,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
createdby INTEGER
);
CREATE SEQUENCE nts.mkt_activitymedia_seq START 1000;
CREATE INDEX idx_media_activity ON nts.mkt_activitymedia(activityid);
1.2.7 Activity Audience Segment Link Table (mkt_activityaudience)¶
CREATE TABLE nts.mkt_activityaudience (
activityaudienceid INTEGER PRIMARY KEY,
activityid INTEGER NOT NULL REFERENCES nts.mkt_activity(activityid),
audienceid INTEGER NOT NULL REFERENCES nts.mkt_audience(audienceid),
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE SEQUENCE nts.mkt_activityaudience_seq START 1000;
CREATE INDEX idx_actaud_activity ON nts.mkt_activityaudience(activityid);
1.2.8 Change Log Table (mkt_changelog)¶
CREATE TABLE nts.mkt_changelog (
logid INTEGER PRIMARY KEY,
entitytype VARCHAR(30) NOT NULL, -- CAMPAIGN, ACTIVITY
entityid INTEGER NOT NULL,
action VARCHAR(50) NOT NULL, -- STATUS_CHANGE, FIELD_UPDATE, CREATE, etc.
oldvalue TEXT,
newvalue TEXT,
fieldname VARCHAR(100),
changedby INTEGER REFERENCES nts.mkt_teammember(teammemberid),
changedat TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
notificationsent BOOLEAN DEFAULT FALSE
);
CREATE SEQUENCE nts.mkt_changelog_seq START 1000;
CREATE INDEX idx_changelog_entity ON nts.mkt_changelog(entitytype, entityid);
CREATE INDEX idx_changelog_date ON nts.mkt_changelog(changedat);
1.3 Entity-Relationship Diagram¶
┌─────────────────┐ ┌─────────────────┐
│ mkt_teammember │ │ mkt_audience │
├─────────────────┤ ├─────────────────┤
│ teammemberid PK │ │ audienceid PK │
│ userid FK │ │ segmentname │
│ displayname │ │ description │
│ whatsappnum │ │ active │
└────────┬────────┘ └────────┬────────┘
│ │
│ creates/reviews │
▼ │
┌─────────────────┐ │
│ mkt_campaign │ │
├─────────────────┤ │
│ campaignid PK │ │
│ campaignname │ │
│ status │ │
│ createdby FK │ │
└────────┬────────┘ │
│ contains │
▼ │
┌─────────────────┐ │
│ mkt_activity │◄─────────────┘
├─────────────────┤ (via mkt_activityaudience)
│ activityid PK │
│ campaignid FK │
│ activitytype │
│ status │
│ storytext │
│ reviewerid FK │
│ createdby FK │
└───┬─────────┬───┘
│ │
▼ ▼
┌────────────┐ ┌─────────────────────┐
│mkt_activity│ │mkt_activityassignment│
│ media │ ├─────────────────────┤
├────────────┤ │ assignmentid PK │
│mediaid PK │ │ activityid FK │
│activityid │ │ teammemberid FK │
│mediaurl │ └─────────────────────┘
└────────────┘
2. Backend Implementation¶
2.1 Module Structure¶
All backend code will be added to the tqapp module following the standard package structure:
tqapp/src/main/java/com/perun/tlinq/
├── client/nts/db/marketing/ # Database entities
│ ├── MktTeammemberEntity.java
│ ├── MktAudienceEntity.java
│ ├── MktCampaignEntity.java
│ ├── MktActivityEntity.java
│ ├── MktActivityassignmentEntity.java
│ ├── MktActivitymediaEntity.java
│ ├── MktActivityaudienceEntity.java
│ └── MktChangelogEntity.java
│
├── entity/marketing/ # Canonical entities
│ ├── CTeamMember.java
│ ├── CAudienceSegment.java
│ ├── CCampaign.java
│ ├── CActivity.java
│ ├── CActivityAssignment.java
│ ├── CActivityMedia.java
│ ├── CActivityAudience.java
│ ├── CChangeLog.java
│ └── MarketingFacade.java # Business facade
2.2 Database Entity Examples¶
2.2.1 MktCampaignEntity.java¶
package com.perun.tlinq.client.nts.db.marketing;
import com.perun.tlinq.client.nts.entity.NTSEntity;
import com.perun.tlinq.annotation.TlinqClientEntity;
import com.perun.tlinq.annotation.TlinqEntityField;
import jakarta.persistence.*;
import java.io.Serializable;
import java.sql.Timestamp;
import java.sql.Date;
@Entity
@Table(name = "mkt_campaign", schema = "nts", catalog = "tlinq")
@TlinqClientEntity
public class MktCampaignEntity extends NTSEntity implements Serializable {
@TlinqEntityField
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "mkt_campaign_gen")
@SequenceGenerator(name = "mkt_campaign_gen", sequenceName = "nts.mkt_campaign_seq", allocationSize = 1)
@Column(name = "campaignid")
private Integer campaignId;
@TlinqEntityField
@Column(name = "campaignname", length = 200)
private String campaignName;
@TlinqEntityField
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@TlinqEntityField
@Column(name = "goals", columnDefinition = "TEXT")
private String goals;
@TlinqEntityField
@Column(name = "targetedapproach", columnDefinition = "TEXT")
private String targetedApproach;
@TlinqEntityField
@Column(name = "status", length = 30)
private String status;
@TlinqEntityField
@Column(name = "startdate")
private Date startDate;
@TlinqEntityField
@Column(name = "enddate")
private Date endDate;
@TlinqEntityField
@Column(name = "createdby")
private Integer createdBy;
@TlinqEntityField
@Column(name = "created")
private Timestamp created;
@TlinqEntityField
@Column(name = "modifiedby")
private Integer modifiedBy;
@TlinqEntityField
@Column(name = "modified")
private Timestamp modified;
// Getters and Setters
public Integer getCampaignId() { return campaignId; }
public void setCampaignId(Integer campaignId) { this.campaignId = campaignId; }
public String getCampaignName() { return campaignName; }
public void setCampaignName(String campaignName) { this.campaignName = campaignName; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getGoals() { return goals; }
public void setGoals(String goals) { this.goals = goals; }
public String getTargetedApproach() { return targetedApproach; }
public void setTargetedApproach(String targetedApproach) { this.targetedApproach = targetedApproach; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Date getStartDate() { return startDate; }
public void setStartDate(Date startDate) { this.startDate = startDate; }
public Date getEndDate() { return endDate; }
public void setEndDate(Date endDate) { this.endDate = endDate; }
public Integer getCreatedBy() { return createdBy; }
public void setCreatedBy(Integer createdBy) { this.createdBy = createdBy; }
public Timestamp getCreated() { return created; }
public void setCreated(Timestamp created) { this.created = created; }
public Integer getModifiedBy() { return modifiedBy; }
public void setModifiedBy(Integer modifiedBy) { this.modifiedBy = modifiedBy; }
public Timestamp getModified() { return modified; }
public void setModified(Timestamp modified) { this.modified = modified; }
}
2.2.2 MktActivityEntity.java¶
package com.perun.tlinq.client.nts.db.marketing;
import com.perun.tlinq.client.nts.entity.NTSEntity;
import com.perun.tlinq.annotation.TlinqClientEntity;
import com.perun.tlinq.annotation.TlinqEntityField;
import jakarta.persistence.*;
import java.io.Serializable;
import java.sql.Timestamp;
import java.sql.Date;
@Entity
@Table(name = "mkt_activity", schema = "nts", catalog = "tlinq")
@TlinqClientEntity
public class MktActivityEntity extends NTSEntity implements Serializable {
@TlinqEntityField
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "mkt_activity_gen")
@SequenceGenerator(name = "mkt_activity_gen", sequenceName = "nts.mkt_activity_seq", allocationSize = 1)
@Column(name = "activityid")
private Integer activityId;
@TlinqEntityField
@Column(name = "campaignid")
private Integer campaignId;
@TlinqEntityField
@Column(name = "activityname", length = 200)
private String activityName;
@TlinqEntityField
@Column(name = "activitytype", length = 30)
private String activityType;
@TlinqEntityField
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@TlinqEntityField
@Column(name = "storytext", columnDefinition = "TEXT")
private String storyText;
@TlinqEntityField
@Column(name = "status", length = 30)
private String status;
@TlinqEntityField
@Column(name = "startdate")
private Date startDate;
@TlinqEntityField
@Column(name = "duedate")
private Date dueDate;
@TlinqEntityField
@Column(name = "reviewerid")
private Integer reviewerId;
@TlinqEntityField
@Column(name = "createdby")
private Integer createdBy;
@TlinqEntityField
@Column(name = "created")
private Timestamp created;
@TlinqEntityField
@Column(name = "modifiedby")
private Integer modifiedBy;
@TlinqEntityField
@Column(name = "modified")
private Timestamp modified;
// Getters and Setters
public Integer getActivityId() { return activityId; }
public void setActivityId(Integer activityId) { this.activityId = activityId; }
public Integer getCampaignId() { return campaignId; }
public void setCampaignId(Integer campaignId) { this.campaignId = campaignId; }
public String getActivityName() { return activityName; }
public void setActivityName(String activityName) { this.activityName = activityName; }
public String getActivityType() { return activityType; }
public void setActivityType(String activityType) { this.activityType = activityType; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getStoryText() { return storyText; }
public void setStoryText(String storyText) { this.storyText = storyText; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Date getStartDate() { return startDate; }
public void setStartDate(Date startDate) { this.startDate = startDate; }
public Date getDueDate() { return dueDate; }
public void setDueDate(Date dueDate) { this.dueDate = dueDate; }
public Integer getReviewerId() { return reviewerId; }
public void setReviewerId(Integer reviewerId) { this.reviewerId = reviewerId; }
public Integer getCreatedBy() { return createdBy; }
public void setCreatedBy(Integer createdBy) { this.createdBy = createdBy; }
public Timestamp getCreated() { return created; }
public void setCreated(Timestamp created) { this.created = created; }
public Integer getModifiedBy() { return modifiedBy; }
public void setModifiedBy(Integer modifiedBy) { this.modifiedBy = modifiedBy; }
public Timestamp getModified() { return modified; }
public void setModified(Timestamp modified) { this.modified = modified; }
}
2.3 Canonical Entity Examples¶
2.3.1 CCampaign.java¶
package com.perun.tlinq.entity.marketing;
import com.perun.tlinq.entity.TlinqEntity;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
public class CCampaign extends TlinqEntity implements Serializable {
private Integer campaignId;
private String campaignName;
private String description;
private String goals;
private String targetedApproach;
private String status;
private Date startDate;
private Date endDate;
private Integer createdBy;
private String createdByName; // Denormalized for display
private Date created;
private Integer modifiedBy;
private Date modified;
// Associated activities (loaded on demand)
private List<CActivity> activities;
// Getters and Setters
public Integer getCampaignId() { return campaignId; }
public void setCampaignId(Integer campaignId) { this.campaignId = campaignId; }
public String getCampaignName() { return campaignName; }
public void setCampaignName(String campaignName) { this.campaignName = campaignName; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getGoals() { return goals; }
public void setGoals(String goals) { this.goals = goals; }
public String getTargetedApproach() { return targetedApproach; }
public void setTargetedApproach(String targetedApproach) { this.targetedApproach = targetedApproach; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Date getStartDate() { return startDate; }
public void setStartDate(Date startDate) { this.startDate = startDate; }
public Date getEndDate() { return endDate; }
public void setEndDate(Date endDate) { this.endDate = endDate; }
public Integer getCreatedBy() { return createdBy; }
public void setCreatedBy(Integer createdBy) { this.createdBy = createdBy; }
public String getCreatedByName() { return createdByName; }
public void setCreatedByName(String createdByName) { this.createdByName = createdByName; }
public Date getCreated() { return created; }
public void setCreated(Date created) { this.created = created; }
public Integer getModifiedBy() { return modifiedBy; }
public void setModifiedBy(Integer modifiedBy) { this.modifiedBy = modifiedBy; }
public Date getModified() { return modified; }
public void setModified(Date modified) { this.modified = modified; }
public List<CActivity> getActivities() { return activities; }
public void setActivities(List<CActivity> activities) { this.activities = activities; }
}
2.3.2 CActivity.java¶
package com.perun.tlinq.entity.marketing;
import com.perun.tlinq.entity.TlinqEntity;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
public class CActivity extends TlinqEntity implements Serializable {
private Integer activityId;
private Integer campaignId;
private String campaignName; // Denormalized for display
private String activityName;
private String activityType;
private String description;
private String storyText;
private String status;
private Date startDate;
private Date dueDate;
private Integer reviewerId;
private String reviewerName; // Denormalized for display
private Integer createdBy;
private String createdByName; // Denormalized for display
private Date created;
private Integer modifiedBy;
private Date modified;
// Associated data (loaded on demand)
private List<CTeamMember> assignees;
private List<CActivityMedia> mediaItems;
private List<CAudienceSegment> audienceSegments;
// Getters and Setters
public Integer getActivityId() { return activityId; }
public void setActivityId(Integer activityId) { this.activityId = activityId; }
public Integer getCampaignId() { return campaignId; }
public void setCampaignId(Integer campaignId) { this.campaignId = campaignId; }
public String getCampaignName() { return campaignName; }
public void setCampaignName(String campaignName) { this.campaignName = campaignName; }
public String getActivityName() { return activityName; }
public void setActivityName(String activityName) { this.activityName = activityName; }
public String getActivityType() { return activityType; }
public void setActivityType(String activityType) { this.activityType = activityType; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getStoryText() { return storyText; }
public void setStoryText(String storyText) { this.storyText = storyText; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Date getStartDate() { return startDate; }
public void setStartDate(Date startDate) { this.startDate = startDate; }
public Date getDueDate() { return dueDate; }
public void setDueDate(Date dueDate) { this.dueDate = dueDate; }
public Integer getReviewerId() { return reviewerId; }
public void setReviewerId(Integer reviewerId) { this.reviewerId = reviewerId; }
public String getReviewerName() { return reviewerName; }
public void setReviewerName(String reviewerName) { this.reviewerName = reviewerName; }
public Integer getCreatedBy() { return createdBy; }
public void setCreatedBy(Integer createdBy) { this.createdBy = createdBy; }
public String getCreatedByName() { return createdByName; }
public void setCreatedByName(String createdByName) { this.createdByName = createdByName; }
public Date getCreated() { return created; }
public void setCreated(Date created) { this.created = created; }
public Integer getModifiedBy() { return modifiedBy; }
public void setModifiedBy(Integer modifiedBy) { this.modifiedBy = modifiedBy; }
public Date getModified() { return modified; }
public void setModified(Date modified) { this.modified = modified; }
public List<CTeamMember> getAssignees() { return assignees; }
public void setAssignees(List<CTeamMember> assignees) { this.assignees = assignees; }
public List<CActivityMedia> getMediaItems() { return mediaItems; }
public void setMediaItems(List<CActivityMedia> mediaItems) { this.mediaItems = mediaItems; }
public List<CAudienceSegment> getAudienceSegments() { return audienceSegments; }
public void setAudienceSegments(List<CAudienceSegment> audienceSegments) { this.audienceSegments = audienceSegments; }
}
2.4 Business Facade¶
2.4.1 MarketingFacade.java¶
package com.perun.tlinq.entity.marketing;
import com.perun.tlinq.entity.EntityFacade;
import com.perun.tlinq.exception.TlinqClientException;
import com.perun.tlinq.search.SelectCriteriaList;
import java.util.List;
import java.util.Map;
public class MarketingFacade extends EntityFacade {
private static final String CAMPAIGN_ENTITY = "Campaign";
private static final String ACTIVITY_ENTITY = "Activity";
private static final String TEAMMEMBER_ENTITY = "TeamMember";
private static final String AUDIENCE_ENTITY = "AudienceSegment";
private CCampaign currentCampaign = null;
private CActivity currentActivity = null;
// Constructor variants
public MarketingFacade(String token) {
super(token);
}
public MarketingFacade(String token, Integer campaignId) throws TlinqClientException {
super(token);
loadCampaign(campaignId);
}
// ==================== Campaign Operations ====================
public void loadCampaign(Integer campaignId) throws TlinqClientException {
currentCampaign = (CCampaign) read(CAMPAIGN_ENTITY, campaignId);
if (currentCampaign != null) {
loadCampaignActivities();
}
}
public List<CCampaign> listCampaigns(SelectCriteriaList criteria) throws TlinqClientException {
return search(CAMPAIGN_ENTITY, criteria);
}
public CCampaign createCampaign(CCampaign campaign) throws TlinqClientException {
campaign.setStatus("CREATED");
return (CCampaign) create(CAMPAIGN_ENTITY, campaign);
}
public CCampaign updateCampaign(CCampaign campaign) throws TlinqClientException {
return (CCampaign) update(CAMPAIGN_ENTITY, campaign);
}
public void changeCampaignStatus(Integer campaignId, String newStatus) throws TlinqClientException {
loadCampaign(campaignId);
validateCampaignStatusTransition(currentCampaign.getStatus(), newStatus);
String oldStatus = currentCampaign.getStatus();
currentCampaign.setStatus(newStatus);
updateCampaign(currentCampaign);
// Log the change
logChange("CAMPAIGN", campaignId, "STATUS_CHANGE", oldStatus, newStatus, "status");
// Send notification
sendStatusChangeNotification("CAMPAIGN", campaignId, oldStatus, newStatus);
}
private void validateCampaignStatusTransition(String from, String to) throws TlinqClientException {
// CREATED -> WORK_IN_PROGRESS
// WORK_IN_PROGRESS -> COMPLETED_SCHEDULED (only if all activities approved)
// COMPLETED_SCHEDULED -> STARTED
// STARTED -> ONGOING | PAUSED | ENDED
// ONGOING -> PAUSED | ENDED
// PAUSED -> ONGOING | ENDED
boolean valid = switch (from) {
case "CREATED" -> "WORK_IN_PROGRESS".equals(to);
case "WORK_IN_PROGRESS" -> "COMPLETED_SCHEDULED".equals(to);
case "COMPLETED_SCHEDULED" -> "STARTED".equals(to) || "WORK_IN_PROGRESS".equals(to);
case "STARTED" -> List.of("ONGOING", "PAUSED", "ENDED").contains(to);
case "ONGOING" -> List.of("PAUSED", "ENDED").contains(to);
case "PAUSED" -> List.of("ONGOING", "ENDED").contains(to);
default -> false;
};
if (!valid) {
throw new TlinqClientException("Invalid status transition from " + from + " to " + to);
}
// Special check: COMPLETED_SCHEDULED requires all activities approved
if ("COMPLETED_SCHEDULED".equals(to)) {
loadCampaignActivities();
for (CActivity activity : currentCampaign.getActivities()) {
if (!"APPROVED".equals(activity.getStatus()) && !"CANCELLED".equals(activity.getStatus())) {
throw new TlinqClientException("Cannot complete campaign: Activity '" +
activity.getActivityName() + "' is not approved");
}
}
}
}
private void loadCampaignActivities() throws TlinqClientException {
SelectCriteriaList criteria = new SelectCriteriaList();
criteria.addCriteria("campaignId", currentCampaign.getCampaignId());
List<CActivity> activities = search(ACTIVITY_ENTITY, criteria);
currentCampaign.setActivities(activities);
}
// ==================== Activity Operations ====================
public void loadActivity(Integer activityId) throws TlinqClientException {
currentActivity = (CActivity) read(ACTIVITY_ENTITY, activityId);
if (currentActivity != null) {
loadActivityDetails();
}
}
public CActivity createActivity(CActivity activity) throws TlinqClientException {
activity.setStatus("CREATED");
CActivity created = (CActivity) create(ACTIVITY_ENTITY, activity);
logChange("ACTIVITY", created.getActivityId(), "CREATE", null, "CREATED", null);
return created;
}
public CActivity updateActivity(CActivity activity) throws TlinqClientException {
if ("APPROVED".equals(activity.getStatus())) {
throw new TlinqClientException("Cannot modify approved activity");
}
return (CActivity) update(ACTIVITY_ENTITY, activity);
}
public void changeActivityStatus(Integer activityId, String newStatus) throws TlinqClientException {
loadActivity(activityId);
validateActivityStatusTransition(currentActivity.getStatus(), newStatus);
String oldStatus = currentActivity.getStatus();
currentActivity.setStatus(newStatus);
update(ACTIVITY_ENTITY, currentActivity);
// Log the change
logChange("ACTIVITY", activityId, "STATUS_CHANGE", oldStatus, newStatus, "status");
// Send notification
sendStatusChangeNotification("ACTIVITY", activityId, oldStatus, newStatus);
// If activity is revoked (back to WORK_IN_PROGRESS), update campaign status
if ("WORK_IN_PROGRESS".equals(newStatus) && "APPROVED".equals(oldStatus)) {
CCampaign campaign = (CCampaign) read(CAMPAIGN_ENTITY, currentActivity.getCampaignId());
if ("COMPLETED_SCHEDULED".equals(campaign.getStatus())) {
campaign.setStatus("WORK_IN_PROGRESS");
update(CAMPAIGN_ENTITY, campaign);
logChange("CAMPAIGN", campaign.getCampaignId(), "STATUS_CHANGE",
"COMPLETED_SCHEDULED", "WORK_IN_PROGRESS", "status");
}
}
}
private void validateActivityStatusTransition(String from, String to) throws TlinqClientException {
// CREATED -> ASSIGNED
// ASSIGNED -> WORK_IN_PROGRESS | CANCELLED
// WORK_IN_PROGRESS -> SENT_FOR_REVIEW | CANCELLED
// SENT_FOR_REVIEW -> APPROVED | WORK_IN_PROGRESS (returned)
// APPROVED -> WORK_IN_PROGRESS (revoked by admin)
boolean valid = switch (from) {
case "CREATED" -> "ASSIGNED".equals(to);
case "ASSIGNED" -> List.of("WORK_IN_PROGRESS", "CANCELLED").contains(to);
case "WORK_IN_PROGRESS" -> List.of("SENT_FOR_REVIEW", "CANCELLED").contains(to);
case "SENT_FOR_REVIEW" -> List.of("APPROVED", "WORK_IN_PROGRESS").contains(to);
case "APPROVED" -> "WORK_IN_PROGRESS".equals(to); // Admin revoke
default -> false;
};
if (!valid) {
throw new TlinqClientException("Invalid status transition from " + from + " to " + to);
}
}
public void sendForReview(Integer activityId, Integer reviewerId) throws TlinqClientException {
loadActivity(activityId);
if (!"WORK_IN_PROGRESS".equals(currentActivity.getStatus())) {
throw new TlinqClientException("Activity must be in WORK_IN_PROGRESS status to send for review");
}
currentActivity.setReviewerId(reviewerId);
currentActivity.setStatus("SENT_FOR_REVIEW");
update(ACTIVITY_ENTITY, currentActivity);
logChange("ACTIVITY", activityId, "SENT_FOR_REVIEW", null, String.valueOf(reviewerId), "reviewerId");
// Send WhatsApp notification to reviewer
sendReviewRequestNotification(activityId, reviewerId);
}
public void approveActivity(Integer activityId) throws TlinqClientException {
changeActivityStatus(activityId, "APPROVED");
sendApprovalNotification(activityId, true);
}
public void returnActivity(Integer activityId, String reason) throws TlinqClientException {
changeActivityStatus(activityId, "WORK_IN_PROGRESS");
sendApprovalNotification(activityId, false);
}
private void loadActivityDetails() throws TlinqClientException {
// Load assignees
SelectCriteriaList assignCriteria = new SelectCriteriaList();
assignCriteria.addCriteria("activityId", currentActivity.getActivityId());
// ... load from assignment entity and resolve team members
// Load media items
// ... similar pattern
// Load audience segments
// ... similar pattern
}
// ==================== Assignment Operations ====================
public void assignActivity(Integer activityId, List<Integer> teamMemberIds) throws TlinqClientException {
loadActivity(activityId);
for (Integer memberId : teamMemberIds) {
CActivityAssignment assignment = new CActivityAssignment();
assignment.setActivityId(activityId);
assignment.setTeamMemberId(memberId);
create("ActivityAssignment", assignment);
}
// Update activity status to ASSIGNED if currently CREATED
if ("CREATED".equals(currentActivity.getStatus())) {
changeActivityStatus(activityId, "ASSIGNED");
}
}
// ==================== Media Operations ====================
public CActivityMedia addMedia(Integer activityId, CActivityMedia media) throws TlinqClientException {
loadActivity(activityId);
if ("APPROVED".equals(currentActivity.getStatus())) {
throw new TlinqClientException("Cannot add media to approved activity");
}
media.setActivityId(activityId);
return (CActivityMedia) create("ActivityMedia", media);
}
public void removeMedia(Integer mediaId) throws TlinqClientException {
// Verify activity is not approved before deleting
CActivityMedia media = (CActivityMedia) read("ActivityMedia", mediaId);
loadActivity(media.getActivityId());
if ("APPROVED".equals(currentActivity.getStatus())) {
throw new TlinqClientException("Cannot remove media from approved activity");
}
delete("ActivityMedia", mediaId);
}
// ==================== Change Logging ====================
private void logChange(String entityType, Integer entityId, String action,
String oldValue, String newValue, String fieldName) throws TlinqClientException {
CChangeLog log = new CChangeLog();
log.setEntityType(entityType);
log.setEntityId(entityId);
log.setAction(action);
log.setOldValue(oldValue);
log.setNewValue(newValue);
log.setFieldName(fieldName);
// changedBy will be set from session
create("ChangeLog", log);
}
// ==================== Notification Methods ====================
private void sendStatusChangeNotification(String entityType, Integer entityId,
String oldStatus, String newStatus) {
// Implementation: Send WhatsApp message to campaign/activity creator
// Use external WhatsApp API service
}
private void sendReviewRequestNotification(Integer activityId, Integer reviewerId) {
// Implementation: Send WhatsApp message to reviewer
}
private void sendApprovalNotification(Integer activityId, boolean approved) {
// Implementation: Send WhatsApp message to activity creator
}
// ==================== Getters ====================
public CCampaign getCurrentCampaign() { return currentCampaign; }
public CActivity getCurrentActivity() { return currentActivity; }
}
2.5 Entity XML Configuration¶
Create file: config/entities/marketing-entities.xml
<!--
Entity configurations for Marketing Planning Module
This file is included in the main tourlinq-config.xml via XInclude
-->
<!-- Team Member Entity -->
<Entity name="TeamMember" class="com.perun.tlinq.entity.marketing.CTeamMember"
idField="teamMemberId" defaultFactory="NTSServiceFactory">
<EntityFactoryList>
<Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktTeammemberEntity">
<ServiceList>
<Service name="saveTeamMember" action="update"/>
<Service name="saveTeamMember" action="create"/>
<Service name="readTeamMember" action="read"/>
<Service name="searchTeamMembers" action="search"/>
</ServiceList>
<FieldMappingList>
<FieldMapping targetField="teamMemberId" sourceField="teammemberid" mapping="DirectMapping"/>
<FieldMapping targetField="userId" sourceField="userid" mapping="DirectMapping"/>
<FieldMapping targetField="displayName" sourceField="displayname" mapping="DirectMapping"/>
<FieldMapping targetField="email" sourceField="email" mapping="DirectMapping"/>
<FieldMapping targetField="whatsappNum" sourceField="whatsappnum" mapping="DirectMapping"/>
<FieldMapping targetField="role" sourceField="role" mapping="DirectMapping"/>
<FieldMapping targetField="active" sourceField="active" mapping="DirectMapping"/>
<FieldMapping targetField="created" sourceField="created" mapping="DirectMapping"/>
<FieldMapping targetField="createdBy" sourceField="createdby" mapping="DirectMapping"/>
</FieldMappingList>
</Factory>
</EntityFactoryList>
</Entity>
<!-- Audience Segment Entity -->
<Entity name="AudienceSegment" class="com.perun.tlinq.entity.marketing.CAudienceSegment"
idField="audienceId" defaultFactory="NTSServiceFactory">
<EntityFactoryList>
<Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktAudienceEntity">
<ServiceList>
<Service name="saveAudience" action="update"/>
<Service name="saveAudience" action="create"/>
<Service name="readAudience" action="read"/>
<Service name="searchAudiences" action="search"/>
</ServiceList>
<FieldMappingList>
<FieldMapping targetField="audienceId" sourceField="audienceid" mapping="DirectMapping"/>
<FieldMapping targetField="segmentName" sourceField="segmentname" mapping="DirectMapping"/>
<FieldMapping targetField="description" sourceField="description" mapping="DirectMapping"/>
<FieldMapping targetField="active" sourceField="active" mapping="DirectMapping"/>
</FieldMappingList>
</Factory>
</EntityFactoryList>
</Entity>
<!-- Campaign Entity -->
<Entity name="Campaign" class="com.perun.tlinq.entity.marketing.CCampaign"
idField="campaignId" defaultFactory="NTSServiceFactory">
<EntityFactoryList>
<Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktCampaignEntity">
<ServiceList>
<Service name="saveCampaign" action="update"/>
<Service name="saveCampaign" action="create"/>
<Service name="readCampaign" action="read"/>
<Service name="searchCampaigns" action="search"/>
</ServiceList>
<FieldMappingList>
<FieldMapping targetField="campaignId" sourceField="campaignid" mapping="DirectMapping"/>
<FieldMapping targetField="campaignName" sourceField="campaignname" mapping="DirectMapping"/>
<FieldMapping targetField="description" sourceField="description" mapping="DirectMapping"/>
<FieldMapping targetField="goals" sourceField="goals" mapping="DirectMapping"/>
<FieldMapping targetField="targetedApproach" sourceField="targetedapproach" mapping="DirectMapping"/>
<FieldMapping targetField="status" sourceField="status" mapping="DirectMapping"/>
<FieldMapping targetField="startDate" sourceField="startdate" mapping="DirectMapping"/>
<FieldMapping targetField="endDate" sourceField="enddate" mapping="DirectMapping"/>
<FieldMapping targetField="createdBy" sourceField="createdby" mapping="DirectMapping"/>
<FieldMapping targetField="created" sourceField="created" mapping="DirectMapping"/>
<FieldMapping targetField="modifiedBy" sourceField="modifiedby" mapping="DirectMapping"/>
<FieldMapping targetField="modified" sourceField="modified" mapping="DirectMapping"/>
</FieldMappingList>
</Factory>
</EntityFactoryList>
</Entity>
<!-- Activity Entity -->
<Entity name="Activity" class="com.perun.tlinq.entity.marketing.CActivity"
idField="activityId" defaultFactory="NTSServiceFactory">
<EntityFactoryList>
<Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktActivityEntity">
<ServiceList>
<Service name="saveActivity" action="update"/>
<Service name="saveActivity" action="create"/>
<Service name="readActivity" action="read"/>
<Service name="searchActivities" action="search"/>
</ServiceList>
<FieldMappingList>
<FieldMapping targetField="activityId" sourceField="activityid" mapping="DirectMapping"/>
<FieldMapping targetField="campaignId" sourceField="campaignid" mapping="DirectMapping"/>
<FieldMapping targetField="activityName" sourceField="activityname" mapping="DirectMapping"/>
<FieldMapping targetField="activityType" sourceField="activitytype" mapping="DirectMapping"/>
<FieldMapping targetField="description" sourceField="description" mapping="DirectMapping"/>
<FieldMapping targetField="storyText" sourceField="storytext" mapping="DirectMapping"/>
<FieldMapping targetField="status" sourceField="status" mapping="DirectMapping"/>
<FieldMapping targetField="startDate" sourceField="startdate" mapping="DirectMapping"/>
<FieldMapping targetField="dueDate" sourceField="duedate" mapping="DirectMapping"/>
<FieldMapping targetField="reviewerId" sourceField="reviewerid" mapping="DirectMapping"/>
<FieldMapping targetField="createdBy" sourceField="createdby" mapping="DirectMapping"/>
<FieldMapping targetField="created" sourceField="created" mapping="DirectMapping"/>
<FieldMapping targetField="modifiedBy" sourceField="modifiedby" mapping="DirectMapping"/>
<FieldMapping targetField="modified" sourceField="modified" mapping="DirectMapping"/>
</FieldMappingList>
</Factory>
</EntityFactoryList>
</Entity>
<!-- Activity Assignment Entity -->
<Entity name="ActivityAssignment" class="com.perun.tlinq.entity.marketing.CActivityAssignment"
idField="assignmentId" defaultFactory="NTSServiceFactory">
<EntityFactoryList>
<Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktActivityassignmentEntity">
<ServiceList>
<Service name="saveAssignment" action="update"/>
<Service name="saveAssignment" action="create"/>
<Service name="readAssignment" action="read"/>
<Service name="searchAssignments" action="search"/>
<Service name="deleteAssignment" action="delete"/>
</ServiceList>
<FieldMappingList>
<FieldMapping targetField="assignmentId" sourceField="assignmentid" mapping="DirectMapping"/>
<FieldMapping targetField="activityId" sourceField="activityid" mapping="DirectMapping"/>
<FieldMapping targetField="teamMemberId" sourceField="teammemberid" mapping="DirectMapping"/>
<FieldMapping targetField="assignedAt" sourceField="assignedat" mapping="DirectMapping"/>
<FieldMapping targetField="assignedBy" sourceField="assignedby" mapping="DirectMapping"/>
<FieldMapping targetField="active" sourceField="active" mapping="DirectMapping"/>
</FieldMappingList>
</Factory>
</EntityFactoryList>
</Entity>
<!-- Activity Media Entity -->
<Entity name="ActivityMedia" class="com.perun.tlinq.entity.marketing.CActivityMedia"
idField="mediaId" defaultFactory="NTSServiceFactory">
<EntityFactoryList>
<Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktActivitymediaEntity">
<ServiceList>
<Service name="saveMedia" action="update"/>
<Service name="saveMedia" action="create"/>
<Service name="readMedia" action="read"/>
<Service name="searchMedia" action="search"/>
<Service name="deleteMedia" action="delete"/>
</ServiceList>
<FieldMappingList>
<FieldMapping targetField="mediaId" sourceField="mediaid" mapping="DirectMapping"/>
<FieldMapping targetField="activityId" sourceField="activityid" mapping="DirectMapping"/>
<FieldMapping targetField="mediaType" sourceField="mediatype" mapping="DirectMapping"/>
<FieldMapping targetField="mediaUrl" sourceField="mediaurl" mapping="DirectMapping"/>
<FieldMapping targetField="fileName" sourceField="filename" mapping="DirectMapping"/>
<FieldMapping targetField="description" sourceField="description" mapping="DirectMapping"/>
<FieldMapping targetField="sortOrder" sourceField="sortorder" mapping="DirectMapping"/>
</FieldMappingList>
</Factory>
</EntityFactoryList>
</Entity>
<!-- Activity Audience Entity -->
<Entity name="ActivityAudience" class="com.perun.tlinq.entity.marketing.CActivityAudience"
idField="activityAudienceId" defaultFactory="NTSServiceFactory">
<EntityFactoryList>
<Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktActivityaudienceEntity">
<ServiceList>
<Service name="saveActivityAudience" action="update"/>
<Service name="saveActivityAudience" action="create"/>
<Service name="searchActivityAudience" action="search"/>
<Service name="deleteActivityAudience" action="delete"/>
</ServiceList>
<FieldMappingList>
<FieldMapping targetField="activityAudienceId" sourceField="activityaudienceid" mapping="DirectMapping"/>
<FieldMapping targetField="activityId" sourceField="activityid" mapping="DirectMapping"/>
<FieldMapping targetField="audienceId" sourceField="audienceid" mapping="DirectMapping"/>
</FieldMappingList>
</Factory>
</EntityFactoryList>
</Entity>
<!-- Change Log Entity -->
<Entity name="ChangeLog" class="com.perun.tlinq.entity.marketing.CChangeLog"
idField="logId" defaultFactory="NTSServiceFactory">
<EntityFactoryList>
<Factory name="NTSServiceFactory" nativeEntity="com.perun.tlinq.client.nts.db.marketing.MktChangelogEntity">
<ServiceList>
<Service name="saveChangeLog" action="create"/>
<Service name="searchChangeLogs" action="search"/>
</ServiceList>
<FieldMappingList>
<FieldMapping targetField="logId" sourceField="logid" mapping="DirectMapping"/>
<FieldMapping targetField="entityType" sourceField="entitytype" mapping="DirectMapping"/>
<FieldMapping targetField="entityId" sourceField="entityid" mapping="DirectMapping"/>
<FieldMapping targetField="action" sourceField="action" mapping="DirectMapping"/>
<FieldMapping targetField="oldValue" sourceField="oldvalue" mapping="DirectMapping"/>
<FieldMapping targetField="newValue" sourceField="newvalue" mapping="DirectMapping"/>
<FieldMapping targetField="fieldName" sourceField="fieldname" mapping="DirectMapping"/>
<FieldMapping targetField="changedBy" sourceField="changedby" mapping="DirectMapping"/>
<FieldMapping targetField="changedAt" sourceField="changedat" mapping="DirectMapping"/>
<FieldMapping targetField="notificationSent" sourceField="notificationsent" mapping="DirectMapping"/>
</FieldMappingList>
</Factory>
</EntityFactoryList>
</Entity>
Update tourlinq-config.xml DOCTYPE to include:
And in the <Entities> section:
3. REST API Implementation¶
3.1 MarketingApi.java¶
Create file: tqapi/src/main/java/com/perun/tlinq/api/MarketingApi.java
package com.perun.tlinq.api;
import com.perun.tlinq.entity.marketing.*;
import com.perun.tlinq.api.entity.TlinqApiResponse;
import com.perun.tlinq.exception.TlinqClientException;
import com.perun.tlinq.search.SelectCriteriaList;
import com.perun.tlinq.TlinqErr;
import jakarta.servlet.http.HttpServlet;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
@Path("/marketing")
public class MarketingApi extends HttpServlet {
private static final Logger logger = Logger.getLogger(MarketingApi.class.getName());
// ==================== Campaign Endpoints ====================
@POST
@Path("/campaign/list")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response listCampaigns(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
logger.info("BEGIN listCampaigns for " + session);
ar = doListCampaigns(session, reqData);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END listCampaigns for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/campaign/read")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response readCampaign(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
Integer campaignId = ApiUtil.gmp(reqData, "campaignId", Integer.class, true);
logger.info("BEGIN readCampaign for " + session + " campaignId=" + campaignId);
ar = doReadCampaign(session, campaignId);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END readCampaign for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/campaign/create")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response createCampaign(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
logger.info("BEGIN createCampaign for " + session);
ar = doCreateCampaign(session, reqData);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END createCampaign for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/campaign/update")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response updateCampaign(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
logger.info("BEGIN updateCampaign for " + session);
ar = doUpdateCampaign(session, reqData);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END updateCampaign for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/campaign/changeStatus")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response changeCampaignStatus(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
Integer campaignId = ApiUtil.gmp(reqData, "campaignId", Integer.class, true);
String newStatus = ApiUtil.gmp(reqData, "status", String.class, true);
logger.info("BEGIN changeCampaignStatus for " + session);
ar = doChangeCampaignStatus(session, campaignId, newStatus);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END changeCampaignStatus for " + session);
return Response.ok(ar).build();
}
// ==================== Activity Endpoints ====================
@POST
@Path("/activity/list")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response listActivities(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
Integer campaignId = ApiUtil.gmp(reqData, "campaignId", Integer.class, false);
logger.info("BEGIN listActivities for " + session);
ar = doListActivities(session, campaignId);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END listActivities for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/activity/read")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response readActivity(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
logger.info("BEGIN readActivity for " + session);
ar = doReadActivity(session, activityId);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END readActivity for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/activity/create")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response createActivity(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
logger.info("BEGIN createActivity for " + session);
ar = doCreateActivity(session, reqData);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END createActivity for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/activity/update")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response updateActivity(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
logger.info("BEGIN updateActivity for " + session);
ar = doUpdateActivity(session, reqData);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END updateActivity for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/activity/changeStatus")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response changeActivityStatus(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
String newStatus = ApiUtil.gmp(reqData, "status", String.class, true);
logger.info("BEGIN changeActivityStatus for " + session);
ar = doChangeActivityStatus(session, activityId, newStatus);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END changeActivityStatus for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/activity/sendForReview")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response sendForReview(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
Integer reviewerId = ApiUtil.gmp(reqData, "reviewerId", Integer.class, true);
logger.info("BEGIN sendForReview for " + session);
ar = doSendForReview(session, activityId, reviewerId);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END sendForReview for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/activity/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response approveActivity(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
logger.info("BEGIN approveActivity for " + session);
ar = doApproveActivity(session, activityId);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END approveActivity for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/activity/return")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response returnActivity(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
String reason = ApiUtil.gmp(reqData, "reason", String.class, false);
logger.info("BEGIN returnActivity for " + session);
ar = doReturnActivity(session, activityId, reason);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END returnActivity for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/activity/assign")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response assignActivity(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
List<Integer> teamMemberIds = ApiUtil.gmp(reqData, "teamMemberIds", List.class, true);
logger.info("BEGIN assignActivity for " + session);
ar = doAssignActivity(session, activityId, teamMemberIds);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END assignActivity for " + session);
return Response.ok(ar).build();
}
// ==================== Media Endpoints ====================
@POST
@Path("/media/add")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response addMedia(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
logger.info("BEGIN addMedia for " + session);
ar = doAddMedia(session, reqData);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END addMedia for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/media/remove")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response removeMedia(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
Integer mediaId = ApiUtil.gmp(reqData, "mediaId", Integer.class, true);
logger.info("BEGIN removeMedia for " + session);
ar = doRemoveMedia(session, mediaId);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END removeMedia for " + session);
return Response.ok(ar).build();
}
// ==================== Reference Data Endpoints ====================
@POST
@Path("/teamMembers/list")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response listTeamMembers(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
logger.info("BEGIN listTeamMembers for " + session);
ar = doListTeamMembers(session);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END listTeamMembers for " + session);
return Response.ok(ar).build();
}
@POST
@Path("/audiences/list")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response listAudiences(Map reqData) {
TlinqApiResponse ar;
String session = "";
try {
session = ApiUtil.gmp(reqData, "session", String.class, true);
logger.info("BEGIN listAudiences for " + session);
ar = doListAudiences(session);
} catch (Exception ex) {
ar = new TlinqApiResponse(TlinqErr.GENERAL, ex.getMessage());
}
logger.info("END listAudiences for " + session);
return Response.ok(ar).build();
}
// ==================== Implementation Methods ====================
private TlinqApiResponse doListCampaigns(String session, Map reqData) throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
SelectCriteriaList criteria = new SelectCriteriaList();
// Add filtering based on reqData if needed
List<CCampaign> campaigns = facade.listCampaigns(criteria);
return new TlinqApiResponse(campaigns);
}
private TlinqApiResponse doReadCampaign(String session, Integer campaignId) throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session, campaignId);
return new TlinqApiResponse(facade.getCurrentCampaign());
}
private TlinqApiResponse doCreateCampaign(String session, Map reqData) throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
CCampaign campaign = new CCampaign();
campaign.setCampaignName(ApiUtil.gmp(reqData, "campaignName", String.class, true));
campaign.setDescription(ApiUtil.gmp(reqData, "description", String.class, false));
campaign.setGoals(ApiUtil.gmp(reqData, "goals", String.class, false));
campaign.setTargetedApproach(ApiUtil.gmp(reqData, "targetedApproach", String.class, false));
// Set more fields as needed
CCampaign created = facade.createCampaign(campaign);
return new TlinqApiResponse(created);
}
private TlinqApiResponse doUpdateCampaign(String session, Map reqData) throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
Integer campaignId = ApiUtil.gmp(reqData, "campaignId", Integer.class, true);
facade.loadCampaign(campaignId);
CCampaign campaign = facade.getCurrentCampaign();
// Update fields from reqData
if (reqData.containsKey("campaignName"))
campaign.setCampaignName(ApiUtil.gmp(reqData, "campaignName", String.class, false));
if (reqData.containsKey("description"))
campaign.setDescription(ApiUtil.gmp(reqData, "description", String.class, false));
if (reqData.containsKey("goals"))
campaign.setGoals(ApiUtil.gmp(reqData, "goals", String.class, false));
if (reqData.containsKey("targetedApproach"))
campaign.setTargetedApproach(ApiUtil.gmp(reqData, "targetedApproach", String.class, false));
CCampaign updated = facade.updateCampaign(campaign);
return new TlinqApiResponse(updated);
}
private TlinqApiResponse doChangeCampaignStatus(String session, Integer campaignId, String newStatus)
throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
facade.changeCampaignStatus(campaignId, newStatus);
return new TlinqApiResponse("Status changed successfully");
}
// Similar implementation methods for activities, media, etc.
// ... (abbreviated for brevity)
private TlinqApiResponse doListActivities(String session, Integer campaignId) throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
SelectCriteriaList criteria = new SelectCriteriaList();
if (campaignId != null) {
criteria.addCriteria("campaignId", campaignId);
}
// Implementation using facade
return new TlinqApiResponse("Activities listed");
}
private TlinqApiResponse doReadActivity(String session, Integer activityId) throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
facade.loadActivity(activityId);
return new TlinqApiResponse(facade.getCurrentActivity());
}
private TlinqApiResponse doCreateActivity(String session, Map reqData) throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
CActivity activity = new CActivity();
activity.setCampaignId(ApiUtil.gmp(reqData, "campaignId", Integer.class, true));
activity.setActivityName(ApiUtil.gmp(reqData, "activityName", String.class, true));
activity.setActivityType(ApiUtil.gmp(reqData, "activityType", String.class, true));
activity.setDescription(ApiUtil.gmp(reqData, "description", String.class, false));
activity.setStoryText(ApiUtil.gmp(reqData, "storyText", String.class, false));
CActivity created = facade.createActivity(activity);
return new TlinqApiResponse(created);
}
private TlinqApiResponse doUpdateActivity(String session, Map reqData) throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
// Implementation
return new TlinqApiResponse("Activity updated");
}
private TlinqApiResponse doChangeActivityStatus(String session, Integer activityId, String newStatus)
throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
facade.changeActivityStatus(activityId, newStatus);
return new TlinqApiResponse("Status changed successfully");
}
private TlinqApiResponse doSendForReview(String session, Integer activityId, Integer reviewerId)
throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
facade.sendForReview(activityId, reviewerId);
return new TlinqApiResponse("Activity sent for review");
}
private TlinqApiResponse doApproveActivity(String session, Integer activityId) throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
facade.approveActivity(activityId);
return new TlinqApiResponse("Activity approved");
}
private TlinqApiResponse doReturnActivity(String session, Integer activityId, String reason)
throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
facade.returnActivity(activityId, reason);
return new TlinqApiResponse("Activity returned");
}
private TlinqApiResponse doAssignActivity(String session, Integer activityId, List<Integer> teamMemberIds)
throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
facade.assignActivity(activityId, teamMemberIds);
return new TlinqApiResponse("Activity assigned");
}
private TlinqApiResponse doAddMedia(String session, Map reqData) throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
Integer activityId = ApiUtil.gmp(reqData, "activityId", Integer.class, true);
CActivityMedia media = new CActivityMedia();
media.setMediaType(ApiUtil.gmp(reqData, "mediaType", String.class, true));
media.setMediaUrl(ApiUtil.gmp(reqData, "mediaUrl", String.class, true));
media.setFileName(ApiUtil.gmp(reqData, "fileName", String.class, false));
media.setDescription(ApiUtil.gmp(reqData, "description", String.class, false));
CActivityMedia created = facade.addMedia(activityId, media);
return new TlinqApiResponse(created);
}
private TlinqApiResponse doRemoveMedia(String session, Integer mediaId) throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
facade.removeMedia(mediaId);
return new TlinqApiResponse("Media removed");
}
private TlinqApiResponse doListTeamMembers(String session) throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
// Implementation
return new TlinqApiResponse("Team members listed");
}
private TlinqApiResponse doListAudiences(String session) throws TlinqClientException {
MarketingFacade facade = new MarketingFacade(session);
// Implementation
return new TlinqApiResponse("Audiences listed");
}
}
3.2 API Role Configuration¶
Add to config/api-roles.properties:
# Marketing Planning API
marketing/campaign/list=agent,admin
marketing/campaign/read=agent,admin
marketing/campaign/create=agent,admin
marketing/campaign/update=agent,admin
marketing/campaign/changeStatus=agent,admin
marketing/activity/list=agent,admin
marketing/activity/read=agent,admin
marketing/activity/create=agent,admin
marketing/activity/update=agent,admin
marketing/activity/changeStatus=agent,admin
marketing/activity/sendForReview=agent,admin
marketing/activity/approve=admin,reviewer
marketing/activity/return=admin,reviewer
marketing/activity/assign=agent,admin
marketing/media/add=agent,admin
marketing/media/remove=agent,admin
marketing/teamMembers/list=agent,admin
marketing/audiences/list=agent,admin
4. Frontend Implementation¶
4.1 File Structure¶
tqweb-adm/
├── mktplan.html # Main marketing planning page
├── js/
│ └── modules/
│ └── mktplan.js # Marketing planning ES6 module
└── css/
└── mktplan.css # (optional) Additional styles
4.2 HTML Page: mktplan.html¶
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Marketing Planning - TQPro Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="css/tqapp.css" rel="stylesheet">
<style>
:root {
--primary-color: #362c5d;
--primary-dark: #2a2149;
--secondary-color: #FFC166;
--success-color: #3adb76;
--warning-color: #ffae00;
--alert-color: #cc4b37;
}
.sidebar-tree {
height: calc(100vh - 200px);
overflow-y: auto;
border-right: 1px solid #dee2e6;
}
.tree-item {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
margin: 2px 0;
}
.tree-item:hover {
background-color: #f8f9fa;
}
.tree-item.selected {
background-color: var(--primary-color);
color: white;
}
.tree-item.campaign {
font-weight: 600;
}
.tree-item.activity {
padding-left: 28px;
font-size: 0.9em;
}
.campaign-header {
background: linear-gradient(to right, #fafafa, #ffffff);
border-bottom: 1px solid #dee2e6;
padding: 20px;
min-height: 150px;
}
.activity-workspace {
padding: 20px;
height: calc(80vh - 200px);
overflow-y: auto;
}
.status-badge {
font-size: 0.75rem;
padding: 4px 8px;
border-radius: 12px;
}
.status-CREATED { background-color: #e9ecef; color: #495057; }
.status-ASSIGNED { background-color: #cce5ff; color: #004085; }
.status-WORK_IN_PROGRESS { background-color: #fff3cd; color: #856404; }
.status-SENT_FOR_REVIEW { background-color: #d4edda; color: #155724; }
.status-APPROVED { background-color: #28a745; color: white; }
.status-CANCELLED { background-color: #dc3545; color: white; }
.status-COMPLETED_SCHEDULED { background-color: #17a2b8; color: white; }
.status-STARTED { background-color: #007bff; color: white; }
.status-ONGOING { background-color: #6610f2; color: white; }
.status-PAUSED { background-color: #fd7e14; color: white; }
.status-ENDED { background-color: #6c757d; color: white; }
.approved-indicator {
background-color: #28a745;
color: white;
padding: 10px 20px;
border-radius: 4px;
font-weight: 600;
text-align: center;
margin-bottom: 15px;
}
.media-gallery {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.media-item {
width: 120px;
height: 120px;
border: 1px solid #dee2e6;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.media-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-item .remove-btn {
position: absolute;
top: 4px;
right: 4px;
background: rgba(220, 53, 69, 0.9);
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
}
.audience-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.audience-tag {
background-color: #e9ecef;
padding: 4px 12px;
border-radius: 16px;
font-size: 0.85rem;
}
.rich-text-editor {
min-height: 200px;
border: 1px solid #ced4da;
border-radius: 4px;
}
.action-toolbar {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
padding: 10px 20px;
}
</style>
</head>
<body>
<!-- Header loaded from template -->
<header id="pgheader" data-load-template="header_bootstrap.html"></header>
<div class="container-fluid mt-3">
<div class="row">
<!-- Left Sidebar: Campaign/Activity Tree -->
<div class="col-md-3">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-diagram-3"></i> Campaigns</span>
<button class="btn btn-sm btn-primary" onclick="MktPlan.showCreateCampaignModal()">
<i class="bi bi-plus"></i>
</button>
</div>
<div class="card-body sidebar-tree" id="campaignTree">
<!-- Campaign tree will be populated here -->
</div>
</div>
</div>
<!-- Main Content Area -->
<div class="col-md-9">
<!-- Action Toolbar -->
<div class="action-toolbar mb-3 rounded">
<div class="btn-group me-2">
<button class="btn btn-outline-primary btn-sm" onclick="MktPlan.createActivity()"
id="btnAddActivity" disabled>
<i class="bi bi-plus-circle"></i> New Activity
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="MktPlan.saveActivity()"
id="btnSaveActivity" disabled>
<i class="bi bi-save"></i> Save
</button>
</div>
<div class="btn-group me-2" id="activityActions" style="display: none;">
<button class="btn btn-outline-info btn-sm" onclick="MktPlan.sendForReview()"
id="btnSendReview">
<i class="bi bi-send"></i> Send for Review
</button>
<button class="btn btn-outline-success btn-sm" onclick="MktPlan.approveActivity()"
id="btnApprove" style="display: none;">
<i class="bi bi-check-circle"></i> Approve
</button>
<button class="btn btn-outline-warning btn-sm" onclick="MktPlan.returnActivity()"
id="btnReturn" style="display: none;">
<i class="bi bi-arrow-return-left"></i> Return
</button>
</div>
<div class="btn-group" id="campaignActions">
<button class="btn btn-outline-primary btn-sm dropdown-toggle"
data-bs-toggle="dropdown" id="btnCampaignStatus" disabled>
Campaign Status
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="MktPlan.changeCampaignStatus('WORK_IN_PROGRESS')">Start Work</a></li>
<li><a class="dropdown-item" href="#" onclick="MktPlan.changeCampaignStatus('COMPLETED_SCHEDULED')">Mark Complete</a></li>
<li><a class="dropdown-item" href="#" onclick="MktPlan.changeCampaignStatus('STARTED')">Start Campaign</a></li>
<li><a class="dropdown-item" href="#" onclick="MktPlan.changeCampaignStatus('PAUSED')">Pause</a></li>
<li><a class="dropdown-item" href="#" onclick="MktPlan.changeCampaignStatus('ENDED')">End Campaign</a></li>
</ul>
</div>
</div>
<!-- Campaign Header Section (20%) -->
<div class="campaign-header rounded-top" id="campaignHeader">
<div class="row">
<div class="col-md-8">
<h4 id="campaignName">Select a Campaign</h4>
<p class="text-muted" id="campaignDesc">Choose a campaign from the left panel to view details</p>
</div>
<div class="col-md-4 text-end">
<span class="status-badge" id="campaignStatus"></span>
<div class="mt-2">
<small class="text-muted" id="campaignDates"></small>
</div>
</div>
</div>
</div>
<!-- Activity Workspace (80%) -->
<div class="card rounded-top-0">
<div class="card-body activity-workspace" id="activityWorkspace">
<div class="text-center text-muted py-5" id="noActivitySelected">
<i class="bi bi-cursor-fill" style="font-size: 3rem;"></i>
<p class="mt-3">Select an activity from the campaign tree or create a new one</p>
</div>
<!-- Activity Form (hidden initially) -->
<div id="activityForm" style="display: none;">
<!-- Approved Indicator -->
<div class="approved-indicator" id="approvedIndicator" style="display: none;">
<i class="bi bi-check-circle-fill"></i> This activity has been approved
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Activity Name</label>
<input type="text" class="form-control" id="activityName">
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<select class="form-select" id="activityType">
<option value="GOOGLE_AD">Google Ad</option>
<option value="INSTAGRAM_REEL">Instagram Reel</option>
<option value="INSTAGRAM_STORY">Instagram Story</option>
<option value="INSTAGRAM_CAROUSEL">Instagram Carousel</option>
<option value="FACEBOOK_REEL">Facebook Reel</option>
<option value="FACEBOOK_STORY">Facebook Story</option>
<option value="TIKTOK_STORY">TikTok Story</option>
<option value="TWITTER_POST">Twitter/X Post</option>
<option value="BLOG_POST">Blog Post</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Status</label>
<span class="status-badge d-block text-center mt-2" id="activityStatus">-</span>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label">Start Date</label>
<input type="date" class="form-control" id="activityStartDate">
</div>
<div class="col-md-4">
<label class="form-label">Due Date</label>
<input type="date" class="form-control" id="activityDueDate">
</div>
<div class="col-md-4">
<label class="form-label">Assigned To</label>
<select class="form-select" id="activityAssignees" multiple>
<!-- Populated dynamically -->
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Description / Goals</label>
<textarea class="form-control" id="activityDescription" rows="3"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Target Audience Segments</label>
<div class="audience-tags" id="audienceTags">
<!-- Audience tags will be added here -->
</div>
<select class="form-select mt-2" id="audienceSelector" onchange="MktPlan.addAudience(this)">
<option value="">+ Add audience segment</option>
<!-- Populated dynamically -->
</select>
</div>
<div class="mb-3">
<label class="form-label">Story Text / Content</label>
<div id="storyTextEditor" class="rich-text-editor"></div>
</div>
<div class="mb-3">
<label class="form-label">Media Artifacts</label>
<div class="media-gallery" id="mediaGallery">
<!-- Media items will be added here -->
</div>
<button class="btn btn-outline-secondary btn-sm mt-2" onclick="MktPlan.showAddMediaModal()">
<i class="bi bi-plus-circle"></i> Add Media
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Alert Container -->
<div id="alertContainer" style="position: fixed; top: 80px; right: 20px; z-index: 9999; max-width: 400px;"></div>
<!-- Create Campaign Modal -->
<div class="modal fade" id="createCampaignModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create New Campaign</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Campaign Name</label>
<input type="text" class="form-control" id="newCampaignName">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control" id="newCampaignDesc" rows="2"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Goals</label>
<textarea class="form-control" id="newCampaignGoals" rows="2"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Targeted Approach</label>
<textarea class="form-control" id="newCampaignApproach" rows="2"></textarea>
</div>
<div class="row">
<div class="col-md-6">
<label class="form-label">Start Date</label>
<input type="date" class="form-control" id="newCampaignStartDate">
</div>
<div class="col-md-6">
<label class="form-label">End Date</label>
<input type="date" class="form-control" id="newCampaignEndDate">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="MktPlan.createCampaign()">Create Campaign</button>
</div>
</div>
</div>
</div>
<!-- Add Media Modal -->
<div class="modal fade" id="addMediaModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Media</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Media Type</label>
<select class="form-select" id="newMediaType">
<option value="IMAGE">Image</option>
<option value="VIDEO">Video</option>
<option value="GRAPHIC">Graphic</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Media URL</label>
<input type="url" class="form-control" id="newMediaUrl" placeholder="https://...">
</div>
<div class="mb-3">
<label class="form-label">File Name (optional)</label>
<input type="text" class="form-control" id="newMediaFileName">
</div>
<div class="mb-3">
<label class="form-label">Description (optional)</label>
<input type="text" class="form-control" id="newMediaDescription">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="MktPlan.addMedia()">Add Media</button>
</div>
</div>
</div>
</div>
<!-- Send for Review Modal -->
<div class="modal fade" id="sendReviewModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Send for Review</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Select Reviewer</label>
<select class="form-select" id="reviewerSelect">
<!-- Populated with team members who have reviewer role -->
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="MktPlan.confirmSendForReview()">Send</button>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
<script src="js/pageutil.js"></script>
<script type="module">
import { tlinq, getUserSession } from './js/modules/globals.js';
window.tlinq = tlinq;
window.getUserSession = getUserSession;
window.globalsReady = true;
window.dispatchEvent(new Event('globalsLoaded'));
</script>
<script type="module">
import * as $page from './js/modules/mktplan.js';
$(document).ready(function() {
loadBootstrapTemplates();
$page.initializePage();
window.MktPlan = $page;
});
</script>
</body>
</html>
4.3 JavaScript Module: js/modules/mktplan.js¶
/**
* Marketing Planning Module
* ES6 module for the Marketing Planning functionality
*/
import { tlinq, getUserSession } from './globals.js';
// ==================== API Client ====================
const API = {
async request(endpoint, data = {}) {
const session = getUserSession();
const requestData = { ...data, session };
return tlinq(`marketing${endpoint}`, requestData);
},
// Campaign operations
async listCampaigns() {
return this.request('/campaign/list');
},
async readCampaign(campaignId) {
return this.request('/campaign/read', { campaignId });
},
async createCampaign(campaign) {
return this.request('/campaign/create', campaign);
},
async updateCampaign(campaign) {
return this.request('/campaign/update', campaign);
},
async changeCampaignStatus(campaignId, status) {
return this.request('/campaign/changeStatus', { campaignId, status });
},
// Activity operations
async listActivities(campaignId) {
return this.request('/activity/list', { campaignId });
},
async readActivity(activityId) {
return this.request('/activity/read', { activityId });
},
async createActivity(activity) {
return this.request('/activity/create', activity);
},
async updateActivity(activity) {
return this.request('/activity/update', activity);
},
async changeActivityStatus(activityId, status) {
return this.request('/activity/changeStatus', { activityId, status });
},
async sendForReview(activityId, reviewerId) {
return this.request('/activity/sendForReview', { activityId, reviewerId });
},
async approveActivity(activityId) {
return this.request('/activity/approve', { activityId });
},
async returnActivity(activityId, reason) {
return this.request('/activity/return', { activityId, reason });
},
async assignActivity(activityId, teamMemberIds) {
return this.request('/activity/assign', { activityId, teamMemberIds });
},
// Media operations
async addMedia(activityId, media) {
return this.request('/media/add', { activityId, ...media });
},
async removeMedia(mediaId) {
return this.request('/media/remove', { mediaId });
},
// Reference data
async listTeamMembers() {
return this.request('/teamMembers/list');
},
async listAudiences() {
return this.request('/audiences/list');
}
};
// ==================== Utility Functions ====================
const Utils = {
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
},
showAlert(type, message, duration = 5000) {
const alertClass = {
'success': 'alert-success',
'error': 'alert-danger',
'warning': 'alert-warning',
'info': 'alert-info'
}[type] || 'alert-info';
const iconClass = {
'success': 'bi-check-circle-fill',
'error': 'bi-exclamation-circle-fill',
'warning': 'bi-exclamation-triangle-fill',
'info': 'bi-info-circle-fill'
}[type] || 'bi-info-circle-fill';
const alert = $(`
<div class="alert ${alertClass} alert-dismissible fade show" role="alert">
<i class="bi ${iconClass} me-2"></i>
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`);
$('#alertContainer').append(alert);
if (duration > 0) {
setTimeout(() => alert.alert('close'), duration);
}
},
sanitizeHtml(html) {
const temp = document.createElement('div');
temp.textContent = html;
return temp.innerHTML;
},
getStatusLabel(status) {
const labels = {
'CREATED': 'Created',
'ASSIGNED': 'Assigned',
'WORK_IN_PROGRESS': 'Work in Progress',
'SENT_FOR_REVIEW': 'Sent for Review',
'APPROVED': 'Approved',
'CANCELLED': 'Cancelled',
'COMPLETED_SCHEDULED': 'Completed & Scheduled',
'STARTED': 'Started',
'ONGOING': 'Ongoing',
'PAUSED': 'Paused',
'ENDED': 'Ended'
};
return labels[status] || status;
}
};
// ==================== Page State ====================
let campaigns = [];
let currentCampaign = null;
let currentActivity = null;
let teamMembers = [];
let audiences = [];
let isDirty = false;
let quillEditor = null;
// ==================== Exported Functions ====================
export async function initializePage() {
await loadReferenceData();
await loadCampaigns();
initializeQuillEditor();
setupEventListeners();
}
function initializeQuillEditor() {
quillEditor = new Quill('#storyTextEditor', {
theme: 'snow',
modules: {
toolbar: [
['bold', 'italic', 'underline'],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
['link'],
['clean']
]
}
});
quillEditor.on('text-change', () => {
isDirty = true;
});
}
function setupEventListeners() {
// Track form changes
$('#activityForm input, #activityForm select, #activityForm textarea').on('change', () => {
isDirty = true;
});
}
async function loadReferenceData() {
try {
const [membersResult, audiencesResult] = await Promise.all([
API.listTeamMembers(),
API.listAudiences()
]);
teamMembers = membersResult.data || [];
audiences = audiencesResult.data || [];
populateTeamMemberSelects();
populateAudienceSelect();
} catch (error) {
Utils.showAlert('error', 'Failed to load reference data: ' + error.message);
}
}
function populateTeamMemberSelects() {
const assigneeSelect = $('#activityAssignees');
const reviewerSelect = $('#reviewerSelect');
assigneeSelect.empty();
reviewerSelect.empty();
teamMembers.forEach(member => {
assigneeSelect.append(`<option value="${member.teamMemberId}">${Utils.sanitizeHtml(member.displayName)}</option>`);
if (member.role === 'reviewer' || member.role === 'admin') {
reviewerSelect.append(`<option value="${member.teamMemberId}">${Utils.sanitizeHtml(member.displayName)}</option>`);
}
});
}
function populateAudienceSelect() {
const select = $('#audienceSelector');
select.empty();
select.append('<option value="">+ Add audience segment</option>');
audiences.forEach(aud => {
select.append(`<option value="${aud.audienceId}">${Utils.sanitizeHtml(aud.segmentName)}</option>`);
});
}
async function loadCampaigns() {
try {
const result = await API.listCampaigns();
campaigns = result.data || [];
renderCampaignTree();
} catch (error) {
Utils.showAlert('error', 'Failed to load campaigns: ' + error.message);
}
}
function renderCampaignTree() {
const tree = $('#campaignTree');
tree.empty();
campaigns.forEach(campaign => {
const campaignItem = $(`
<div class="tree-item campaign" data-campaign-id="${campaign.campaignId}">
<i class="bi bi-folder2"></i>
${Utils.sanitizeHtml(campaign.campaignName)}
<span class="status-badge status-${campaign.status} ms-2">${Utils.getStatusLabel(campaign.status)}</span>
</div>
`);
campaignItem.on('click', () => selectCampaign(campaign.campaignId));
tree.append(campaignItem);
// Add activities under campaign
if (campaign.activities && campaign.activities.length > 0) {
campaign.activities.forEach(activity => {
const activityItem = $(`
<div class="tree-item activity" data-activity-id="${activity.activityId}" data-campaign-id="${campaign.campaignId}">
<i class="bi bi-file-earmark"></i>
${Utils.sanitizeHtml(activity.activityName)}
<span class="status-badge status-${activity.status}">${Utils.getStatusLabel(activity.status)}</span>
</div>
`);
activityItem.on('click', (e) => {
e.stopPropagation();
selectActivity(activity.activityId, campaign.campaignId);
});
tree.append(activityItem);
});
}
});
}
async function selectCampaign(campaignId) {
if (isDirty) {
if (!confirm('You have unsaved changes. Discard them?')) {
return;
}
}
try {
const result = await API.readCampaign(campaignId);
currentCampaign = result.entity || result.data;
// Update UI
$('.tree-item').removeClass('selected');
$(`.tree-item[data-campaign-id="${campaignId}"]`).addClass('selected');
renderCampaignHeader();
// Enable campaign actions
$('#btnAddActivity').prop('disabled', false);
$('#btnCampaignStatus').prop('disabled', false);
// Select first activity if exists
if (currentCampaign.activities && currentCampaign.activities.length > 0) {
selectActivity(currentCampaign.activities[0].activityId, campaignId);
} else {
currentActivity = null;
$('#noActivitySelected').show();
$('#activityForm').hide();
}
isDirty = false;
} catch (error) {
Utils.showAlert('error', 'Failed to load campaign: ' + error.message);
}
}
function renderCampaignHeader() {
if (!currentCampaign) return;
$('#campaignName').text(currentCampaign.campaignName);
$('#campaignDesc').text(currentCampaign.description || 'No description');
$('#campaignStatus').text(Utils.getStatusLabel(currentCampaign.status))
.attr('class', `status-badge status-${currentCampaign.status}`);
let dateText = '';
if (currentCampaign.startDate) {
dateText += `Start: ${Utils.formatDate(currentCampaign.startDate)}`;
}
if (currentCampaign.endDate) {
dateText += ` | End: ${Utils.formatDate(currentCampaign.endDate)}`;
}
$('#campaignDates').text(dateText);
}
async function selectActivity(activityId, campaignId) {
if (isDirty) {
if (!confirm('You have unsaved changes. Discard them?')) {
return;
}
}
try {
const result = await API.readActivity(activityId);
currentActivity = result.entity || result.data;
// Update selection in tree
$('.tree-item').removeClass('selected');
$(`.tree-item[data-activity-id="${activityId}"]`).addClass('selected');
// Make sure campaign is also loaded
if (!currentCampaign || currentCampaign.campaignId !== campaignId) {
const campaignResult = await API.readCampaign(campaignId);
currentCampaign = campaignResult.entity || campaignResult.data;
renderCampaignHeader();
}
renderActivityForm();
isDirty = false;
} catch (error) {
Utils.showAlert('error', 'Failed to load activity: ' + error.message);
}
}
function renderActivityForm() {
if (!currentActivity) return;
$('#noActivitySelected').hide();
$('#activityForm').show();
// Populate form fields
$('#activityName').val(currentActivity.activityName);
$('#activityType').val(currentActivity.activityType);
$('#activityStartDate').val(currentActivity.startDate ? currentActivity.startDate.substring(0, 10) : '');
$('#activityDueDate').val(currentActivity.dueDate ? currentActivity.dueDate.substring(0, 10) : '');
$('#activityDescription').val(currentActivity.description || '');
// Set status badge
$('#activityStatus')
.text(Utils.getStatusLabel(currentActivity.status))
.attr('class', `status-badge status-${currentActivity.status}`);
// Set story text in Quill editor
if (currentActivity.storyText) {
quillEditor.root.innerHTML = currentActivity.storyText;
} else {
quillEditor.root.innerHTML = '';
}
// Render assignees, media, audiences
renderAssignees();
renderMediaGallery();
renderAudienceTags();
// Update button visibility based on status
updateActionButtons();
// Show approved indicator if approved
if (currentActivity.status === 'APPROVED') {
$('#approvedIndicator').show();
disableActivityForm();
} else {
$('#approvedIndicator').hide();
enableActivityForm();
}
}
function updateActionButtons() {
const status = currentActivity?.status;
$('#btnSaveActivity').prop('disabled', !currentActivity || status === 'APPROVED');
$('#activityActions').toggle(!!currentActivity);
// Show/hide specific buttons based on status
$('#btnSendReview').toggle(status === 'WORK_IN_PROGRESS');
$('#btnApprove').toggle(status === 'SENT_FOR_REVIEW');
$('#btnReturn').toggle(status === 'SENT_FOR_REVIEW');
}
function disableActivityForm() {
$('#activityForm input, #activityForm select, #activityForm textarea').prop('disabled', true);
quillEditor.disable();
}
function enableActivityForm() {
$('#activityForm input, #activityForm select, #activityForm textarea').prop('disabled', false);
quillEditor.enable();
}
function renderAssignees() {
if (currentActivity && currentActivity.assignees) {
const ids = currentActivity.assignees.map(a => a.teamMemberId);
$('#activityAssignees').val(ids);
}
}
function renderMediaGallery() {
const gallery = $('#mediaGallery');
gallery.empty();
if (currentActivity && currentActivity.mediaItems) {
currentActivity.mediaItems.forEach(media => {
const item = $(`
<div class="media-item">
${media.mediaType === 'VIDEO'
? `<i class="bi bi-play-circle" style="font-size: 2rem; display: flex; align-items: center; justify-content: center; height: 100%;"></i>`
: `<img src="${Utils.sanitizeHtml(media.mediaUrl)}" alt="${Utils.sanitizeHtml(media.fileName || '')}">`
}
${currentActivity.status !== 'APPROVED'
? `<button class="remove-btn" onclick="MktPlan.removeMedia(${media.mediaId})"><i class="bi bi-x"></i></button>`
: ''
}
</div>
`);
gallery.append(item);
});
}
}
function renderAudienceTags() {
const container = $('#audienceTags');
container.empty();
if (currentActivity && currentActivity.audienceSegments) {
currentActivity.audienceSegments.forEach(aud => {
const tag = $(`
<span class="audience-tag">
${Utils.sanitizeHtml(aud.segmentName)}
${currentActivity.status !== 'APPROVED'
? `<i class="bi bi-x ms-1" style="cursor: pointer;" onclick="MktPlan.removeAudience(${aud.audienceId})"></i>`
: ''
}
</span>
`);
container.append(tag);
});
}
}
// ==================== Campaign Operations ====================
export function showCreateCampaignModal() {
// Clear form
$('#newCampaignName').val('');
$('#newCampaignDesc').val('');
$('#newCampaignGoals').val('');
$('#newCampaignApproach').val('');
$('#newCampaignStartDate').val('');
$('#newCampaignEndDate').val('');
const modal = new bootstrap.Modal(document.getElementById('createCampaignModal'));
modal.show();
}
export async function createCampaign() {
const campaignData = {
campaignName: $('#newCampaignName').val(),
description: $('#newCampaignDesc').val(),
goals: $('#newCampaignGoals').val(),
targetedApproach: $('#newCampaignApproach').val(),
startDate: $('#newCampaignStartDate').val() || null,
endDate: $('#newCampaignEndDate').val() || null
};
if (!campaignData.campaignName) {
Utils.showAlert('warning', 'Campaign name is required');
return;
}
try {
await API.createCampaign(campaignData);
Utils.showAlert('success', 'Campaign created successfully');
// Hide modal and reload campaigns
bootstrap.Modal.getInstance(document.getElementById('createCampaignModal')).hide();
await loadCampaigns();
} catch (error) {
Utils.showAlert('error', 'Failed to create campaign: ' + error.message);
}
}
export async function changeCampaignStatus(newStatus) {
if (!currentCampaign) return;
try {
await API.changeCampaignStatus(currentCampaign.campaignId, newStatus);
Utils.showAlert('success', 'Campaign status updated');
await selectCampaign(currentCampaign.campaignId);
await loadCampaigns();
} catch (error) {
Utils.showAlert('error', 'Failed to change status: ' + error.message);
}
}
// ==================== Activity Operations ====================
export async function createActivity() {
if (!currentCampaign) {
Utils.showAlert('warning', 'Please select a campaign first');
return;
}
const activityName = prompt('Enter activity name:');
if (!activityName) return;
try {
const activity = {
campaignId: currentCampaign.campaignId,
activityName: activityName,
activityType: 'INSTAGRAM_REEL' // Default type
};
const result = await API.createActivity(activity);
Utils.showAlert('success', 'Activity created');
await loadCampaigns();
await selectActivity(result.entity.activityId, currentCampaign.campaignId);
} catch (error) {
Utils.showAlert('error', 'Failed to create activity: ' + error.message);
}
}
export async function saveActivity() {
if (!currentActivity || currentActivity.status === 'APPROVED') return;
try {
const activityData = {
activityId: currentActivity.activityId,
activityName: $('#activityName').val(),
activityType: $('#activityType').val(),
description: $('#activityDescription').val(),
storyText: quillEditor.root.innerHTML,
startDate: $('#activityStartDate').val() || null,
dueDate: $('#activityDueDate').val() || null
};
await API.updateActivity(activityData);
// Handle assignees
const selectedAssignees = $('#activityAssignees').val() || [];
await API.assignActivity(currentActivity.activityId, selectedAssignees.map(Number));
Utils.showAlert('success', 'Activity saved');
isDirty = false;
await loadCampaigns();
} catch (error) {
Utils.showAlert('error', 'Failed to save activity: ' + error.message);
}
}
export function sendForReview() {
if (!currentActivity) return;
const modal = new bootstrap.Modal(document.getElementById('sendReviewModal'));
modal.show();
}
export async function confirmSendForReview() {
const reviewerId = $('#reviewerSelect').val();
if (!reviewerId) {
Utils.showAlert('warning', 'Please select a reviewer');
return;
}
try {
await API.sendForReview(currentActivity.activityId, parseInt(reviewerId));
Utils.showAlert('success', 'Activity sent for review');
bootstrap.Modal.getInstance(document.getElementById('sendReviewModal')).hide();
await selectActivity(currentActivity.activityId, currentCampaign.campaignId);
await loadCampaigns();
} catch (error) {
Utils.showAlert('error', 'Failed to send for review: ' + error.message);
}
}
export async function approveActivity() {
if (!currentActivity) return;
try {
await API.approveActivity(currentActivity.activityId);
Utils.showAlert('success', 'Activity approved');
await selectActivity(currentActivity.activityId, currentCampaign.campaignId);
await loadCampaigns();
} catch (error) {
Utils.showAlert('error', 'Failed to approve activity: ' + error.message);
}
}
export async function returnActivity() {
if (!currentActivity) return;
const reason = prompt('Reason for returning (optional):');
try {
await API.returnActivity(currentActivity.activityId, reason);
Utils.showAlert('info', 'Activity returned for revision');
await selectActivity(currentActivity.activityId, currentCampaign.campaignId);
await loadCampaigns();
} catch (error) {
Utils.showAlert('error', 'Failed to return activity: ' + error.message);
}
}
// ==================== Media Operations ====================
export function showAddMediaModal() {
if (!currentActivity || currentActivity.status === 'APPROVED') return;
$('#newMediaType').val('IMAGE');
$('#newMediaUrl').val('');
$('#newMediaFileName').val('');
$('#newMediaDescription').val('');
const modal = new bootstrap.Modal(document.getElementById('addMediaModal'));
modal.show();
}
export async function addMedia() {
const mediaUrl = $('#newMediaUrl').val();
if (!mediaUrl) {
Utils.showAlert('warning', 'Media URL is required');
return;
}
try {
const media = {
mediaType: $('#newMediaType').val(),
mediaUrl: mediaUrl,
fileName: $('#newMediaFileName').val(),
description: $('#newMediaDescription').val()
};
await API.addMedia(currentActivity.activityId, media);
Utils.showAlert('success', 'Media added');
bootstrap.Modal.getInstance(document.getElementById('addMediaModal')).hide();
await selectActivity(currentActivity.activityId, currentCampaign.campaignId);
} catch (error) {
Utils.showAlert('error', 'Failed to add media: ' + error.message);
}
}
export async function removeMedia(mediaId) {
if (!confirm('Remove this media item?')) return;
try {
await API.removeMedia(mediaId);
Utils.showAlert('success', 'Media removed');
await selectActivity(currentActivity.activityId, currentCampaign.campaignId);
} catch (error) {
Utils.showAlert('error', 'Failed to remove media: ' + error.message);
}
}
// ==================== Audience Operations ====================
export function addAudience(select) {
const audienceId = select.value;
if (!audienceId || !currentActivity || currentActivity.status === 'APPROVED') {
select.value = '';
return;
}
// Check if already added
if (currentActivity.audienceSegments?.some(a => a.audienceId === parseInt(audienceId))) {
Utils.showAlert('info', 'Audience segment already added');
select.value = '';
return;
}
// Add to local state and re-render
const audience = audiences.find(a => a.audienceId === parseInt(audienceId));
if (audience) {
if (!currentActivity.audienceSegments) {
currentActivity.audienceSegments = [];
}
currentActivity.audienceSegments.push(audience);
renderAudienceTags();
isDirty = true;
}
select.value = '';
}
export function removeAudience(audienceId) {
if (!currentActivity || currentActivity.status === 'APPROVED') return;
currentActivity.audienceSegments = currentActivity.audienceSegments.filter(
a => a.audienceId !== audienceId
);
renderAudienceTags();
isDirty = true;
}
5. WhatsApp Notification Service¶
5.1 Notification Service Interface¶
package com.perun.tlinq.service.notification;
public interface NotificationService {
void sendWhatsAppMessage(String phoneNumber, String message);
void sendStatusChangeNotification(String entityType, Integer entityId,
String oldStatus, String newStatus, Integer userId);
void sendReviewRequestNotification(Integer activityId, Integer reviewerId);
void sendApprovalNotification(Integer activityId, boolean approved, Integer userId);
}
5.2 Implementation Options¶
The WhatsApp notification service can be implemented using:
- WhatsApp Business API (recommended for production)
- Twilio WhatsApp API
- MessageBird WhatsApp API
Configuration should be added to tlinqapi.properties:
# WhatsApp Notification Configuration
whatsapp.enabled=true
whatsapp.provider=twilio
whatsapp.account.sid=your_account_sid
whatsapp.auth.token=your_auth_token
whatsapp.from.number=+14155238886
6. Implementation Phases¶
Phase 1: Database & Backend Core¶
- Create database tables and sequences
- Implement database entities (8 entities)
- Implement canonical entities (8 entities)
- Create entity XML configuration file
- Update tourlinq-config.xml with XInclude
Phase 2: Business Logic¶
- Implement MarketingFacade with:
- Campaign CRUD operations
- Activity CRUD operations
- Status transition logic
- Assignment management
- Media management
- Change logging
Phase 3: REST API¶
- Implement MarketingApi endpoints
- Configure API role permissions
- Test API endpoints
Phase 4: Frontend¶
- Create HTML page structure
- Implement JavaScript module
- Build campaign tree navigation
- Implement activity workspace
- Add rich text editor integration
- Implement media gallery
Phase 5: Notifications¶
- Implement WhatsApp notification service
- Integrate with status change events
- Configure notification templates
Phase 6: Testing & Documentation¶
- Unit tests for facade methods
- Integration tests for API endpoints
- End-to-end testing
- User documentation
7. Status Flow Diagrams¶
7.1 Campaign Status Flow¶
┌─────────────┐
│ CREATED │
└──────┬──────┘
│
▼
┌─────────────────────┐
┌────►│ WORK_IN_PROGRESS │◄────┐
│ └──────────┬──────────┘ │
│ │ │
│ (activity │ (all activities│
│ revoked) │ approved) │
│ ▼ │
│ ┌─────────────────────┐ │
└─────│ COMPLETED_SCHEDULED │─────┘
└──────────┬──────────┘
│
▼
┌─────────────┐
│ STARTED │
└──────┬──────┘
│
┌─────────┼─────────┐
▼ │ ▼
┌──────────┐ │ ┌──────────┐
│ PAUSED │◄────┼───│ ONGOING │
└────┬─────┘ │ └────┬─────┘
│ │ │
└───────────┼────────┘
▼
┌──────────┐
│ ENDED │
└──────────┘
7.2 Activity Status Flow¶
┌─────────────┐ ┌──────────┐ ┌─────────────────────┐
│ CREATED │────►│ ASSIGNED │────►│ WORK_IN_PROGRESS │
└─────────────┘ └────┬─────┘ └──────────┬──────────┘
│ │
│ │
▼ ▼
┌────────────┐ ┌─────────────────┐
│ CANCELLED │ │ SENT_FOR_REVIEW │
└────────────┘ └────────┬────────┘
│
┌──────────┴──────────┐
│ │
▼ ▼
┌──────────┐ ┌─────────────────────┐
│ APPROVED │◄───│ WORK_IN_PROGRESS │
└────┬─────┘ │ (returned) │
│ └─────────────────────┘
│
▼
(Admin revoke)
│
▼
┌─────────────────────┐
│ WORK_IN_PROGRESS │
└─────────────────────┘
8. Security Considerations¶
- Role-Based Access Control: API endpoints protected by role-based permissions
- Data Validation: All input validated at API and facade layers
- Audit Logging: All changes logged in mkt_changelog table
- Session Management: Session token required for all API calls
- XSS Prevention: HTML sanitization in frontend
- SQL Injection Prevention: Parameterized queries via JPA
9. File Checklist¶
Backend Files to Create:¶
| File | Location |
|---|---|
| MktTeammemberEntity.java | tqapp/.../client/nts/db/marketing/ |
| MktAudienceEntity.java | tqapp/.../client/nts/db/marketing/ |
| MktCampaignEntity.java | tqapp/.../client/nts/db/marketing/ |
| MktActivityEntity.java | tqapp/.../client/nts/db/marketing/ |
| MktActivityassignmentEntity.java | tqapp/.../client/nts/db/marketing/ |
| MktActivitymediaEntity.java | tqapp/.../client/nts/db/marketing/ |
| MktActivityaudienceEntity.java | tqapp/.../client/nts/db/marketing/ |
| MktChangelogEntity.java | tqapp/.../client/nts/db/marketing/ |
| CTeamMember.java | tqapp/.../entity/marketing/ |
| CAudienceSegment.java | tqapp/.../entity/marketing/ |
| CCampaign.java | tqapp/.../entity/marketing/ |
| CActivity.java | tqapp/.../entity/marketing/ |
| CActivityAssignment.java | tqapp/.../entity/marketing/ |
| CActivityMedia.java | tqapp/.../entity/marketing/ |
| CActivityAudience.java | tqapp/.../entity/marketing/ |
| CChangeLog.java | tqapp/.../entity/marketing/ |
| MarketingFacade.java | tqapp/.../entity/marketing/ |
| MarketingApi.java | tqapi/.../api/ |
| marketing-entities.xml | config/entities/ |
Frontend Files to Create:¶
| File | Location |
|---|---|
| mktplan.html | tqweb-adm/ |
| mktplan.js | tqweb-adm/js/modules/ |
Files to Update:¶
| File | Change |
|---|---|
| tourlinq-config.xml | Add marketing-entities.xml include |
| api-roles.properties | Add marketing API permissions |
| header_bootstrap.html | Add Marketing Planning menu item |
10. Testing Strategy¶
Unit Tests¶
- Test status transition validation logic
- Test facade methods for CRUD operations
- Test field mapping transformations
Integration Tests¶
- API endpoint tests with mock data
- Database operations with test schema
- WhatsApp notification mock tests
End-to-End Tests¶
- Campaign creation workflow
- Activity management workflow
- Review and approval workflow
- Status change notifications
This implementation plan provides a comprehensive blueprint for building the Marketing Planning module following TQPro architectural patterns and best practices.
Broadcast Extension (TQ-109) — IMPLEMENTED¶
Added WhatsApp and Email broadcast capability as an extension to the Marketing module. Implementation completed 2026-04-03.
Components Added¶
- DB Migration:
0058-marketing-broadcast.sql— 3 new tables (mkt_broadcast, mkt_broadcast_recipient, mkt_broadcast_optout), ALTER mkt_audience - DB Entities: MktBroadcastEntity, MktBroadcastRecipientEntity, MktBroadcastOptoutEntity
- Canonical Entities: CBroadcast, CBroadcastRecipient, CBroadcastOptout
- BroadcastService: Async send orchestrator with Hazelcast distributed locks and rate limiting
- MarketingFacade: Extended with broadcast CRUD, recipient resolution, opt-out management
- MarketingApi: 12 new endpoints (broadcast CRUD, Twilio webhooks, email unsubscribe)
- Frontend: Broadcast panel in mktplan.html/js with conditional rendering per activity type
- Config: Broadcast properties in tourlinq.properties, role permissions in api-roles.properties
- Email Templates: File-based HTML templates in config/email-templates/marketing/
See doc/features/marketing/broadcast.md for full feature documentation.