add company stats

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-10-23 12:19:53 +02:00
parent 343212c248
commit 061ef4b623
10 changed files with 843 additions and 71 deletions
+8
View File
@@ -144,6 +144,10 @@ const (
ROUTE_V1_CAMPAIGN_STATS = "/api/v1/campaign/statistics"
ROUTE_V1_CAMPAIGN_STATS_ID = "/api/v1/campaign/:id/stats"
ROUTE_V1_CAMPAIGN_STATS_ALL = "/api/v1/campaign/stats/all"
ROUTE_V1_CAMPAIGN_STATS_CREATE = "/api/v1/campaign/stats"
ROUTE_V1_CAMPAIGN_STATS_MANUAL = "/api/v1/campaign/stats/manual"
ROUTE_V1_CAMPAIGN_STATS_UPDATE = "/api/v1/campaign/stats/:id"
ROUTE_V1_CAMPAIGN_STATS_DELETE = "/api/v1/campaign/stats/:id"
ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED = "/api/v1/campaign/:id/upload/reported"
// campaign-recipient
ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email"
@@ -381,6 +385,10 @@ func setupRoutes(
GET(ROUTE_V1_CAMPAIGN_RESULT_STATS, middleware.SessionHandler, controllers.Campaign.GetResultStats).
GET(ROUTE_V1_CAMPAIGN_STATS_ID, middleware.SessionHandler, controllers.Campaign.GetCampaignStats).
GET(ROUTE_V1_CAMPAIGN_STATS_ALL, middleware.SessionHandler, controllers.Campaign.GetAllCampaignStats).
POST(ROUTE_V1_CAMPAIGN_STATS_CREATE, middleware.SessionHandler, controllers.Campaign.CreateCampaignStats).
GET(ROUTE_V1_CAMPAIGN_STATS_MANUAL, middleware.SessionHandler, controllers.Campaign.GetManualCampaignStats).
PUT(ROUTE_V1_CAMPAIGN_STATS_UPDATE, middleware.SessionHandler, controllers.Campaign.UpdateCampaignStats).
DELETE(ROUTE_V1_CAMPAIGN_STATS_DELETE, middleware.SessionHandler, controllers.Campaign.DeleteCampaignStatsManual).
GET(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.GetByID).
GET(ROUTE_V1_CAMPAIGN_NAME, middleware.SessionHandler, controllers.Campaign.GetByName).
POST(ROUTE_V1_CAMPAIGN, middleware.SessionHandler, controllers.Campaign.Create).
+97 -6
View File
@@ -946,21 +946,112 @@ func (c *Campaign) GetAllCampaignStats(g *gin.Context) {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
companyID := companyIDFromRequestQuery(g)
// get stats
stats, err := c.CampaignService.GetAllCampaignStats(g.Request.Context(), session, queryArgs, companyID)
stats, err := c.CampaignService.GetAllCampaignStats(g.Request.Context(), session, companyID)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// CreateCampaignStats creates manual campaign statistics
func (c *Campaign) CreateCampaignStats(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request body
var req database.CampaignStats
if err := g.ShouldBindJSON(&req); err != nil {
c.Response.BadRequest(g)
return
}
// create stats
stats, err := c.CampaignService.CreateManualCampaignStats(g.Request.Context(), session, &req)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetManualCampaignStats gets all manual campaign statistics (those without campaignID)
func (c *Campaign) GetManualCampaignStats(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
// get manual stats (those with null campaignID)
stats, err := c.CampaignService.GetManualCampaignStats(g.Request.Context(), session, companyID)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// UpdateCampaignStats updates manual campaign statistics by ID
func (c *Campaign) UpdateCampaignStats(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// parse request body
var req database.CampaignStats
if err := g.ShouldBindJSON(&req); err != nil {
c.Response.BadRequest(g)
return
}
// update stats
req.ID = id
stats, err := c.CampaignService.UpdateManualCampaignStats(g.Request.Context(), session, &req)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// DeleteCampaignStatsManual deletes manual campaign statistics by ID
func (c *Campaign) DeleteCampaignStatsManual(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete manual stats
err := c.CampaignService.DeleteManualCampaignStats(g.Request.Context(), session, id)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{"message": "Campaign stats deleted successfully"})
}
// UploadReportedCSV uploads a CSV file with reported recipients
func (c *Campaign) UploadReportedCSV(g *gin.Context) {
// handle session
+1 -1
View File
@@ -17,7 +17,7 @@ type CampaignStats struct {
UpdatedAt *time.Time `gorm:"not null;" json:"updatedAt"`
// Campaign reference
CampaignID *uuid.UUID `gorm:"not null;unique;index;type:uuid;" json:"campaignId"`
CampaignID *uuid.UUID `gorm:"index;type:uuid;" json:"campaignId"`
CampaignName string `gorm:"not null;" json:"campaignName"`
CompanyID *uuid.UUID `gorm:"index;type:uuid;" json:"companyId"` // nullable for global campaigns
+54 -28
View File
@@ -1666,42 +1666,22 @@ func (r *Campaign) GetCampaignStats(ctx context.Context, campaignID *uuid.UUID)
return &stats, nil
}
// GetAllCampaignStats retrieves campaign statistics with pagination and filtering
func (r *Campaign) GetAllCampaignStats(ctx context.Context, companyID *uuid.UUID, options *vo.QueryArgs) ([]database.CampaignStats, error) {
// GetAllCampaignStats retrieves all campaign statistics
func (r *Campaign) GetAllCampaignStats(ctx context.Context, companyID *uuid.UUID) ([]database.CampaignStats, error) {
var stats []database.CampaignStats
db := r.DB.WithContext(ctx)
db := r.DB.WithContext(ctx).Order("created_at DESC")
if companyID != nil {
db = db.Where("company_id = ?", companyID)
}
if options != nil {
if options.Search != "" {
db = db.Where("campaign_name ILIKE ?", "%"+options.Search+"%")
}
if options.OrderBy != "" {
sortColumn := options.OrderBy
if options.Desc {
sortColumn += " DESC"
}
db = db.Order(sortColumn)
} else {
db = db.Order("campaign_closed_at DESC")
}
if options.Limit > 0 {
db = db.Offset(options.Offset).Limit(options.Limit)
}
}
res := db.Find(&stats)
return stats, res.Error
}
// GetCampaignStatsCount returns the total count of campaign statistics
func (r *Campaign) GetCampaignStatsCount(ctx context.Context, companyID *uuid.UUID, search string) (int64, error) {
func (r *Campaign) GetCampaignStatsCount(ctx context.Context, companyID *uuid.UUID) (int64, error) {
var count int64
db := r.DB.WithContext(ctx).Model(&database.CampaignStats{})
@@ -1710,10 +1690,6 @@ func (r *Campaign) GetCampaignStatsCount(ctx context.Context, companyID *uuid.UU
db = db.Where("company_id = ?", companyID)
}
if search != "" {
db = db.Where("campaign_name ILIKE ?", "%"+search+"%")
}
res := db.Count(&count)
return count, res.Error
}
@@ -1723,3 +1699,53 @@ func (r *Campaign) DeleteCampaignStats(ctx context.Context, campaignID *uuid.UUI
res := r.DB.WithContext(ctx).Where("campaign_id = ?", campaignID).Delete(&database.CampaignStats{})
return res.Error
}
// GetManualCampaignStats retrieves manual campaign statistics (those with null campaignID)
func (r *Campaign) GetManualCampaignStats(ctx context.Context, companyID *uuid.UUID) ([]database.CampaignStats, error) {
var stats []database.CampaignStats
db := r.DB.WithContext(ctx).Where("campaign_id IS NULL").Order("created_at DESC")
if companyID != nil {
db = db.Where("company_id = ?", companyID)
}
res := db.Find(&stats)
return stats, res.Error
}
// GetManualCampaignStatsCount returns the total count of manual campaign statistics
func (r *Campaign) GetManualCampaignStatsCount(ctx context.Context, companyID *uuid.UUID) (int64, error) {
var count int64
db := r.DB.WithContext(ctx).Model(&database.CampaignStats{}).Where("campaign_id IS NULL")
if companyID != nil {
db = db.Where("company_id = ?", companyID)
}
res := db.Count(&count)
return count, res.Error
}
// GetCampaignStatsByID retrieves campaign statistics by stats ID
func (r *Campaign) GetCampaignStatsByID(ctx context.Context, statsID *uuid.UUID) (*database.CampaignStats, error) {
var stats database.CampaignStats
res := r.DB.WithContext(ctx).Where("id = ?", statsID).First(&stats)
if res.Error != nil {
return nil, res.Error
}
return &stats, nil
}
// UpdateCampaignStats updates campaign statistics by ID
func (r *Campaign) UpdateCampaignStats(ctx context.Context, statsID *uuid.UUID, updateData map[string]interface{}) error {
res := r.DB.WithContext(ctx).Model(&database.CampaignStats{}).Where("id = ?", statsID).Updates(updateData)
return res.Error
}
// DeleteCampaignStatsByID deletes campaign statistics by stats ID
func (r *Campaign) DeleteCampaignStatsByID(ctx context.Context, statsID *uuid.UUID) error {
res := r.DB.WithContext(ctx).Where("id = ?", statsID).Delete(&database.CampaignStats{})
return res.Error
}
+196 -4
View File
@@ -3838,8 +3838,8 @@ func (c *Campaign) GetCampaignStats(ctx context.Context, session *model.Session,
return c.CampaignRepository.GetCampaignStats(ctx, campaignID)
}
// GetAllCampaignStats retrieves all campaign statistics with pagination
func (c *Campaign) GetAllCampaignStats(ctx context.Context, session *model.Session, options *vo.QueryArgs, companyID *uuid.UUID) (*model.Result[database.CampaignStats], error) {
// GetAllCampaignStats retrieves all campaign statistics
func (c *Campaign) GetAllCampaignStats(ctx context.Context, session *model.Session, companyID *uuid.UUID) (*model.Result[database.CampaignStats], error) {
result := model.NewEmptyResult[database.CampaignStats]()
// Check permissions
@@ -3853,7 +3853,7 @@ func (c *Campaign) GetAllCampaignStats(ctx context.Context, session *model.Sessi
}
// Get the data
stats, err := c.CampaignRepository.GetAllCampaignStats(ctx, companyID, options)
stats, err := c.CampaignRepository.GetAllCampaignStats(ctx, companyID)
if err != nil {
return result, errs.Wrap(err)
}
@@ -3865,11 +3865,203 @@ func (c *Campaign) GetAllCampaignStats(ctx context.Context, session *model.Sessi
}
result.Rows = rows
result.HasNextPage = false // Simple implementation without pagination for now
result.HasNextPage = false
return result, nil
}
// CreateManualCampaignStats creates campaign statistics manually without requiring a campaign
func (c *Campaign) CreateManualCampaignStats(ctx context.Context, session *model.Session, req *database.CampaignStats) (*database.CampaignStats, error) {
// Check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
return nil, errs.ErrAuthorizationFailed
}
// Set up the stats record
id := uuid.New()
now := time.Now()
// Use provided date for created_at and updated_at, or current time if not provided
var statsDate time.Time
if req.CampaignStartDate != nil {
statsDate = *req.CampaignStartDate
} else {
statsDate = now
}
// Set required fields
req.ID = &id
req.CreatedAt = &statsDate
req.UpdatedAt = &statsDate
req.CampaignID = nil // No campaign reference for manual stats
// Calculate rates
if req.TotalRecipients > 0 {
req.OpenRate = float64(req.TrackingPixelLoaded) / float64(req.TotalRecipients) * 100
req.ClickRate = float64(req.WebsiteVisits) / float64(req.TotalRecipients) * 100
req.SubmissionRate = float64(req.DataSubmissions) / float64(req.TotalRecipients) * 100
req.ReportRate = float64(req.Reported) / float64(req.TotalRecipients) * 100
}
// Calculate total events
req.TotalEvents = req.EmailsSent + req.TrackingPixelLoaded + req.WebsiteVisits + req.DataSubmissions + req.Reported
// Insert the stats
c.Logger.Debugf("inserting manual campaign stats", "statsID", req.ID.String())
err = c.CampaignRepository.InsertCampaignStats(ctx, req)
if err != nil {
c.Logger.Errorw("failed to insert manual campaign stats", "error", err, "statsID", req.ID.String())
return nil, errs.Wrap(err)
}
c.Logger.Debugf("successfully inserted manual campaign stats", "statsID", req.ID.String())
return req, nil
}
// GetManualCampaignStats retrieves manual campaign statistics (those without campaignID)
func (c *Campaign) GetManualCampaignStats(ctx context.Context, session *model.Session, companyID *uuid.UUID) (*model.Result[database.CampaignStats], error) {
result := model.NewEmptyResult[database.CampaignStats]()
// Check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
return result, errs.ErrAuthorizationFailed
}
// Get manual stats (those with null campaignID)
stats, err := c.CampaignRepository.GetManualCampaignStats(ctx, companyID)
if err != nil {
return result, errs.Wrap(err)
}
// Convert to result format with pointers
rows := make([]*database.CampaignStats, len(stats))
for i := range stats {
rows[i] = &stats[i]
}
result.Rows = rows
result.HasNextPage = false
return result, nil
}
// UpdateManualCampaignStats updates manual campaign statistics
func (c *Campaign) UpdateManualCampaignStats(ctx context.Context, session *model.Session, req *database.CampaignStats) (*database.CampaignStats, error) {
// Check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
return nil, errs.ErrAuthorizationFailed
}
// Get existing stats to ensure it's manual (no campaignID)
existingStats, err := c.CampaignRepository.GetCampaignStatsByID(ctx, req.ID)
if err != nil {
return nil, errs.Wrap(err)
}
// Ensure this is a manual stat (no campaignID)
if existingStats.CampaignID != nil {
return nil, errs.Wrap(errors.New("cannot update system-generated campaign stats"))
}
// Set updated timestamp
now := time.Now()
req.UpdatedAt = &now
req.CampaignID = nil // Ensure it remains manual
// Calculate rates
if req.TotalRecipients > 0 {
req.OpenRate = float64(req.TrackingPixelLoaded) / float64(req.TotalRecipients) * 100
req.ClickRate = float64(req.WebsiteVisits) / float64(req.TotalRecipients) * 100
req.SubmissionRate = float64(req.DataSubmissions) / float64(req.TotalRecipients) * 100
req.ReportRate = float64(req.Reported) / float64(req.TotalRecipients) * 100
}
// Calculate total events
req.TotalEvents = req.EmailsSent + req.TrackingPixelLoaded + req.WebsiteVisits + req.DataSubmissions + req.Reported
// Prepare update data
updateData := map[string]interface{}{
"updated_at": req.UpdatedAt,
"campaign_name": req.CampaignName,
"company_id": req.CompanyID,
"campaign_start_date": req.CampaignStartDate,
"campaign_end_date": req.CampaignEndDate,
"campaign_closed_at": req.CampaignClosedAt,
"total_recipients": req.TotalRecipients,
"total_events": req.TotalEvents,
"emails_sent": req.EmailsSent,
"tracking_pixel_loaded": req.TrackingPixelLoaded,
"website_visits": req.WebsiteVisits,
"data_submissions": req.DataSubmissions,
"reported": req.Reported,
"open_rate": req.OpenRate,
"click_rate": req.ClickRate,
"submission_rate": req.SubmissionRate,
"report_rate": req.ReportRate,
"template_name": req.TemplateName,
"campaign_type": req.CampaignType,
}
// Update the stats
err = c.CampaignRepository.UpdateCampaignStats(ctx, req.ID, updateData)
if err != nil {
c.Logger.Errorw("failed to update manual campaign stats", "error", err, "statsID", req.ID.String())
return nil, errs.Wrap(err)
}
c.Logger.Debugf("successfully updated manual campaign stats", "statsID", req.ID.String())
return req, nil
}
// DeleteManualCampaignStats deletes manual campaign statistics by ID
func (c *Campaign) DeleteManualCampaignStats(ctx context.Context, session *model.Session, statsID *uuid.UUID) error {
// Check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
return errs.ErrAuthorizationFailed
}
// Get existing stats to ensure it's manual (no campaignID)
existingStats, err := c.CampaignRepository.GetCampaignStatsByID(ctx, statsID)
if err != nil {
return errs.Wrap(err)
}
// Ensure this is a manual stat (no campaignID)
if existingStats.CampaignID != nil {
return errs.Wrap(errors.New("cannot delete system-generated campaign stats"))
}
// Delete the stats
err = c.CampaignRepository.DeleteCampaignStatsByID(ctx, statsID)
if err != nil {
c.Logger.Errorw("failed to delete manual campaign stats", "error", err, "statsID", statsID.String())
return errs.Wrap(err)
}
c.Logger.Debugf("successfully deleted manual campaign stats", "statsID", statsID.String())
return nil
}
// ProcessReportedCSV processes a CSV file with reported recipients
func (c *Campaign) ProcessReportedCSV(
ctx context.Context,
+72 -8
View File
@@ -862,18 +862,82 @@ export class API {
},
/**
* Get all campaign statistics with pagination.
* Get all campaign statistics.
*
* @param {TableURLParams} options
* @param {string|null} companyID
* @returns {Promise<ApiResponse>}
*/
getAllCampaignStats: async (options, companyID = null) => {
return await getJSON(
this.getPath(
`/campaign/stats/all?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`
)
);
getAllCampaignStats: async (companyID = null) => {
const companyQuery = companyID ? `?${this.companyQuery(companyID)}` : '';
return await getJSON(this.getPath(`/campaign/stats/all${companyQuery}`));
},
/**
* Get all manual campaign statistics (those without campaignID).
*
* @param {string|null} companyID
* @returns {Promise<ApiResponse>}
*/
getManualCampaignStats: async (companyID = null) => {
const companyQuery = companyID ? `?${this.companyQuery(companyID)}` : '';
return await getJSON(this.getPath(`/campaign/stats/manual${companyQuery}`));
},
/**
* Create manual campaign statistics.
*
* @param {object} stats
* @param {string} stats.campaignName
* @param {string} [stats.companyId]
* @param {number} stats.totalRecipients
* @param {number} stats.emailsSent
* @param {number} stats.trackingPixelLoaded
* @param {number} stats.websiteVisits
* @param {number} stats.dataSubmissions
* @param {number} stats.reported
* @param {string} [stats.templateName]
* @param {string} [stats.campaignType]
* @param {string} [stats.campaignStartDate] - ISO date string
* @param {string} [stats.campaignEndDate] - ISO date string
* @param {string} [stats.campaignClosedAt] - ISO date string
* @returns {Promise<ApiResponse>}
*/
createStats: async (stats) => {
return await postJSON(this.getPath('/campaign/stats'), stats);
},
/**
* Update manual campaign statistics by ID.
*
* @param {string} statsID
* @param {object} stats
* @param {string} stats.campaignName
* @param {string} [stats.companyId]
* @param {number} stats.totalRecipients
* @param {number} stats.emailsSent
* @param {number} stats.trackingPixelLoaded
* @param {number} stats.websiteVisits
* @param {number} stats.dataSubmissions
* @param {number} stats.reported
* @param {string} [stats.templateName]
* @param {string} [stats.campaignType]
* @param {string} [stats.campaignStartDate] - ISO date string
* @param {string} [stats.campaignEndDate] - ISO date string
* @param {string} [stats.campaignClosedAt] - ISO date string
* @returns {Promise<ApiResponse>}
*/
updateStats: async (statsID, stats) => {
return await putJSON(this.getPath(`/campaign/stats/${statsID}`), stats);
},
/**
* Delete manual campaign statistics by ID.
*
* @param {string} statsID
* @returns {Promise<ApiResponse>}
*/
deleteStats: async (statsID) => {
return await deleteJSON(this.getPath(`/campaign/stats/${statsID}`));
}
};
+5
View File
@@ -1,6 +1,7 @@
<script>
import { api } from '$lib/api/apiProxy.js';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import Headline from '$lib/components/Headline.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
@@ -324,6 +325,10 @@
on:click={() => openViewCommentModal(company)}
/>
<TableDropDownButton name="Export" on:click={() => openExportCompanyModal(company)} />
<TableDropDownButton
name="Custom Stats"
on:click={() => goto(`/company/${company.id}/stats`)}
/>
<TableDeleteButton on:click={() => openDeleteAlert(company)} />
</TableDropDownEllipsis>
</TableCellAction>
@@ -0,0 +1,399 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api } from '$lib/api/apiProxy.js';
import Headline from '$lib/components/Headline.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import FormError from '$lib/components/FormError.svelte';
import { addToast } from '$lib/store/toast';
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import Modal from '$lib/components/Modal.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
import Table from '$lib/components/table/Table.svelte';
import HeadTitle from '$lib/components/HeadTitle.svelte';
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import TextField from '$lib/components/TextField.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
// Get company ID from URL params
$: companyId = $page.params.id;
// bindings
let form = null;
const formValues = {
id: null,
campaignName: '',
totalRecipients: '',
emailsSent: '',
trackingPixelLoaded: '',
websiteVisits: '',
dataSubmissions: '',
reported: '',
date: ''
};
// data
let modalError = '';
let customStats = [];
let company = {
name: ''
};
let isModalVisible = false;
let isSubmitting = false;
let isTableLoading = true;
let modalMode = null;
let modalText = '';
let isDeleteAlertVisible = false;
let deleteValues = {
id: null,
name: null
};
$: {
modalText = modalMode === 'create' ? 'New Custom Stats' : 'Update Custom Stats';
}
// hooks
onMount(() => {
refreshData();
});
// component logic
const refreshData = async () => {
await Promise.all([getCompany(), refreshCustomStats()]);
};
const getCompany = async () => {
try {
const res = await api.company.getByID(companyId);
if (res.success) {
company = res.data;
} else {
throw res.error;
}
} catch (e) {
addToast('Failed to get company', 'Error');
console.error('failed to get company', e);
}
};
const refreshCustomStats = async () => {
try {
isTableLoading = true;
customStats = await getCustomStats();
} catch (e) {
addToast('Failed to get custom stats', 'Error');
console.error('failed to get custom stats', e);
} finally {
isTableLoading = false;
}
};
const getCustomStats = async () => {
try {
const res = await api.campaign.getManualCampaignStats(companyId);
if (res.success) {
return res.data.rows;
}
throw new res.error();
} catch (e) {
addToast('Failed to get custom stats', 'Error');
console.error('failed to get custom stats', e);
}
return [];
};
const getStatsById = async (id) => {
try {
// We'll need to implement this endpoint or get it from the list
const stat = customStats.find((s) => s.id === id);
return stat;
} catch (e) {
addToast('Failed to get stats', 'Error');
console.error('failed to get stats', e);
}
};
const onSubmit = async () => {
try {
isSubmitting = true;
if (modalMode === 'create') {
await create();
} else {
await update();
}
} finally {
isSubmitting = false;
}
};
const create = async () => {
modalError = '';
try {
const campaignDate = formValues.date ? new Date(formValues.date).toISOString() : null;
const payload = {
campaignName: formValues.campaignName,
totalRecipients: parseInt(formValues.totalRecipients) || 0,
emailsSent: parseInt(formValues.emailsSent) || 0,
trackingPixelLoaded: parseInt(formValues.trackingPixelLoaded) || 0,
websiteVisits: parseInt(formValues.websiteVisits) || 0,
dataSubmissions: parseInt(formValues.dataSubmissions) || 0,
reported: parseInt(formValues.reported) || 0,
campaignType: 'Scheduled',
templateName: '',
companyId: companyId,
campaignStartDate: campaignDate,
campaignEndDate: campaignDate,
campaignClosedAt: campaignDate
};
const res = await api.campaign.createStats(payload);
if (!res.success) {
modalError = res.error;
return;
}
addToast('Custom stats created', 'Success');
closeModal();
} catch (e) {
addToast('Failed to create custom stats', 'Error');
console.error('failed to create custom stats:', e);
}
refreshCustomStats();
};
const update = async () => {
modalError = '';
try {
const campaignDate = formValues.date ? new Date(formValues.date).toISOString() : null;
const payload = {
campaignName: formValues.campaignName,
totalRecipients: parseInt(formValues.totalRecipients) || 0,
emailsSent: parseInt(formValues.emailsSent) || 0,
trackingPixelLoaded: parseInt(formValues.trackingPixelLoaded) || 0,
websiteVisits: parseInt(formValues.websiteVisits) || 0,
dataSubmissions: parseInt(formValues.dataSubmissions) || 0,
reported: parseInt(formValues.reported) || 0,
campaignType: 'Scheduled',
templateName: '',
companyId: companyId,
campaignStartDate: campaignDate,
campaignEndDate: campaignDate,
campaignClosedAt: campaignDate
};
const res = await api.campaign.updateStats(formValues.id, payload);
if (!res.success) {
modalError = res.error;
return;
}
addToast('Custom stats updated', 'Success');
closeUpdateModal();
} catch (e) {
addToast('Failed to update custom stats', 'Error');
console.error('failed to update custom stats', e);
}
refreshCustomStats();
};
const openDeleteAlert = async (stats) => {
isDeleteAlertVisible = true;
deleteValues.id = stats.id;
deleteValues.name = stats.campaignName;
};
const onClickDelete = async (id) => {
const action = api.campaign.deleteStats(id);
action
.then((res) => {
if (!res.success) {
throw res.error;
}
refreshCustomStats();
})
.catch((e) => {
console.error('failed to delete custom stats:', e);
});
return action;
};
const openCreateModal = () => {
modalMode = 'create';
modalError = '';
resetFormValues();
isModalVisible = true;
};
const closeModal = () => {
modalError = '';
isModalVisible = false;
resetFormValues();
if (form) form.reset();
};
const openUpdateModal = async (id) => {
modalMode = 'update';
try {
showIsLoading();
const stats = await getStatsById(id);
if (stats) {
formValues.id = stats.id;
formValues.campaignName = stats.campaignName;
formValues.totalRecipients = stats.totalRecipients.toString();
formValues.emailsSent = stats.emailsSent.toString();
formValues.trackingPixelLoaded = stats.trackingPixelLoaded.toString();
formValues.websiteVisits = stats.websiteVisits.toString();
formValues.dataSubmissions = stats.dataSubmissions.toString();
formValues.reported = stats.reported.toString();
formValues.date = stats.campaignStartDate
? new Date(stats.campaignStartDate).toISOString().slice(0, 16)
: '';
isModalVisible = true;
}
} catch (e) {
addToast('Failed to get stats', 'Error');
console.error('failed to get stats', e);
} finally {
hideIsLoading();
}
};
const closeUpdateModal = () => {
isModalVisible = false;
modalError = '';
resetFormValues();
if (form) form.reset();
};
const resetFormValues = () => {
formValues.id = null;
formValues.campaignName = '';
formValues.totalRecipients = '';
formValues.emailsSent = '';
formValues.trackingPixelLoaded = '';
formValues.websiteVisits = '';
formValues.dataSubmissions = '';
formValues.reported = '';
formValues.date = '';
};
</script>
<HeadTitle title="Custom Campaign Stats" />
<main>
<Headline>
Custom Stats - {company.name}
</Headline>
<BigButton on:click={openCreateModal}>Add</BigButton>
<Table
columns={[
{ column: 'Campaign Name', size: 'large' },
{ column: 'Recipients', size: 'small' },
{ column: 'Open Rate', size: 'small' },
{ column: 'Click Rate', size: 'small' },
{ column: 'Submission Rate', size: 'small' },
{ column: 'Report Rate', size: 'small' },
{ column: 'Date', size: 'small' }
]}
sortable={[]}
hasData={!!customStats.length}
plural="custom statistics"
isGhost={isTableLoading}
>
{#each customStats as stats}
<TableRow>
<TableCell>
<button
on:click={() => openUpdateModal(stats.id)}
class="block w-full py-1 text-left font-medium text-gray-900 dark:text-gray-100"
>
{stats.campaignName}
</button>
</TableCell>
<TableCell value={stats.totalRecipients} />
<TableCell value="{Math.round(stats.openRate)}%" />
<TableCell value="{Math.round(stats.clickRate)}%" />
<TableCell value="{Math.round(stats.submissionRate)}%" />
<TableCell value="{Math.round(stats.reportRate)}%" />
<TableCell value={stats.createdAt} isDate isRelative />
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableUpdateButton on:click={() => openUpdateModal(stats.id)} />
<TableDeleteButton on:click={() => openDeleteAlert(stats)} />
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting} {modalMode}>
<FormColumns>
<FormColumn>
<TextField
minLength={1}
maxLength={64}
required
bind:value={formValues.campaignName}
placeholder="Campaign Name">Campaign Name</TextField
>
<TextField
type="number"
min="0"
required
bind:value={formValues.totalRecipients}
placeholder="0">Total Recipients</TextField
>
<TextField type="number" min="0" bind:value={formValues.emailsSent} placeholder="0"
>Emails Sent</TextField
>
<TextField
type="number"
min="0"
bind:value={formValues.trackingPixelLoaded}
placeholder="0">Email Opens (Read)</TextField
>
</FormColumn>
<FormColumn>
<TextField type="number" min="0" bind:value={formValues.websiteVisits} placeholder="0"
>Links Clicked</TextField
>
<TextField type="number" min="0" bind:value={formValues.dataSubmissions} placeholder="0"
>Data Submissions</TextField
>
<TextField type="number" min="0" bind:value={formValues.reported} placeholder="0"
>Reported as Phishing</TextField
>
<TextField type="datetime-local" required bind:value={formValues.date}>Date</TextField>
</FormColumn>
</FormColumns>
<FormError message={modalError} />
<FormFooter {closeModal} {isSubmitting} />
</FormGrid>
</Modal>
<DeleteAlert
list={['All statistics data will be permanently removed']}
name={deleteValues.name}
onClick={() => onClickDelete(deleteValues.id)}
bind:isVisible={isDeleteAlertVisible}
/>
</main>
+11 -21
View File
@@ -261,15 +261,7 @@
sortOrder: 'desc',
perPage: 10
});
const options = {
page: statsParams.currentPage,
perPage: statsParams.perPage,
sortBy: statsParams.sortBy,
sortOrder: statsParams.sortOrder,
search: statsParams.search,
includeTest: includeTestCampaigns
};
const res = await api.campaign.getAllCampaignStats(options, contextCompanyID);
const res = await api.campaign.getAllCampaignStats(contextCompanyID);
if (!res.success) {
throw res.error;
}
@@ -372,15 +364,7 @@
completedCampaigns = completedRes.data.rows;
}
const statsOptions = {
page: 1,
perPage: 10,
sortBy: 'campaign_closed_at',
sortOrder: 'desc',
search: '',
includeTest: includeTestCampaigns
};
const statsRes = await api.campaign.getAllCampaignStats(statsOptions, contextCompanyID);
const statsRes = await api.campaign.getAllCampaignStats(contextCompanyID);
if (statsRes.success) {
campaignStats = [];
await tick();
@@ -650,9 +634,15 @@
{#each campaignStats as stat}
<TableRow>
<TableCell>
<a href={`/campaign/${stat.campaignId}`}>
{stat.campaignName}
</a>
{#if stat.campaignId}
<a href={`/campaign/${stat.campaignId}`}>
{stat.campaignName}
</a>
{:else}
<span class="text-gray-600 dark:text-gray-400">
{stat.campaignName}
</span>
{/if}
</TableCell>
<TableCell value={stat.templateName} />
<TableCell value={stat.totalRecipients} />
-3
View File
@@ -8,7 +8,6 @@
import TextField from '$lib/components/TextField.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableCellLink from '$lib/components/table/TableCellLink.svelte';
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import FormError from '$lib/components/FormError.svelte';
@@ -19,8 +18,6 @@
import Modal from '$lib/components/Modal.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
import Table from '$lib/components/table/Table.svelte';
import HeadTitle from '$lib/components/HeadTitle.svelte';